Skip to content

Commit 6b6d6dd

Browse files
committed
feat: allow Parameter attributes on properties
1 parent effc99e commit 6b6d6dd

7 files changed

Lines changed: 555 additions & 1 deletion

File tree

src/Metadata/QueryParameter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
namespace ApiPlatform\Metadata;
1515

16-
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
16+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE | \Attribute::TARGET_PROPERTY)]
1717
class QueryParameter extends Parameter implements QueryParameterInterface
1818
{
1919
}

src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,10 @@ private function getDefaultParameters(Operation $operation, string $resourceClas
231231
$propertyNames = $properties = [];
232232
$parameters = $operation->getParameters() ?? new Parameters();
233233

234+
foreach ($this->createParametersFromAttributes($operation) as $key => $parameter) {
235+
$parameters->add($key, $parameter);
236+
}
237+
234238
// First loop we look for the :property placeholder and replace its key
235239
foreach ($parameters as $key => $parameter) {
236240
if (!str_contains($key, ':property')) {
@@ -466,4 +470,38 @@ private function getFilterInstance(object|string|null $filter): ?object
466470

467471
return $this->filterLocator->get($filter);
468472
}
473+
474+
private function createParametersFromAttributes(Operation $operation): Parameters
475+
{
476+
$parameters = new Parameters();
477+
478+
if (null === $resourceClass = $operation->getClass()) {
479+
return $parameters;
480+
}
481+
482+
foreach ((new \ReflectionClass($resourceClass))->getProperties() as $reflectionProperty) {
483+
foreach ($reflectionProperty->getAttributes(Parameter::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
484+
$parameter = $attribute->newInstance();
485+
486+
$propertyName = $reflectionProperty->getName();
487+
$key = $parameter->getKey() ?? $propertyName;
488+
489+
if (null === $parameterPropertyName = $parameter->getProperty()) {
490+
$parameter = $parameter->withProperty($propertyName);
491+
} elseif ($parameterPropertyName !== $propertyName) {
492+
throw new RuntimeException(\sprintf('Parameter attribute on property "%s" must target itself or have no explicit property. Got "property: \'%s\'" instead.', $propertyName, $parameterPropertyName));
493+
}
494+
495+
if (null === ($parameterProperties = $parameter->getProperties()) || \in_array($propertyName, $parameterProperties, true)) {
496+
$parameter = $parameter->withProperties([$propertyName]);
497+
} elseif (!\in_array($propertyName, $parameterProperties, true)) {
498+
throw new RuntimeException(\sprintf('Parameter attribute on property "%s" must target itself or have no explicit properties. Got "properties: [%s]" instead.', $propertyName, implode(', ', $parameterProperties)));
499+
}
500+
501+
$parameters->add($key, $parameter);
502+
}
503+
}
504+
505+
return $parameters;
506+
}
469507
}

src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Metadata\ApiProperty;
1717
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Exception\RuntimeException;
1819
use ApiPlatform\Metadata\FilterInterface;
1920
use ApiPlatform\Metadata\GetCollection;
2021
use ApiPlatform\Metadata\Parameters;
@@ -282,6 +283,193 @@ public function testParameterFactoryWithLimitedProperties(): void
282283
$this->assertSame(['name'], $param->getProperties());
283284
}
284285

286+
public function testQueryParameterFromPropertyAttributes(): void
287+
{
288+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
289+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'isActive']));
290+
291+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
292+
$propertyMetadata->method('create')->willReturn(
293+
new ApiProperty(readable: true),
294+
);
295+
296+
$filterLocator = $this->createStub(ContainerInterface::class);
297+
$filterLocator->method('has')->willReturn(false);
298+
299+
$parameterFactory = new ParameterResourceMetadataCollectionFactory(
300+
$nameCollection,
301+
$propertyMetadata,
302+
new AttributesResourceMetadataCollectionFactory(),
303+
$filterLocator
304+
);
305+
306+
$resourceMetadataCollection = $parameterFactory->create(ParameterOnProperties::class);
307+
$operation = $resourceMetadataCollection->getOperation(forceCollection: true);
308+
$parameters = $operation->getParameters();
309+
310+
$this->assertInstanceOf(Parameters::class, $parameters);
311+
312+
$this->assertTrue($parameters->has('search'));
313+
$searchParam = $parameters->get('search', QueryParameter::class);
314+
$this->assertInstanceOf(QueryParameter::class, $searchParam);
315+
$this->assertSame('search', $searchParam->getKey());
316+
$this->assertSame('name', $searchParam->getProperty());
317+
$this->assertSame('Search by name', $searchParam->getDescription());
318+
319+
$this->assertTrue($parameters->has('filter_active'));
320+
$filterParam = $parameters->get('filter_active', QueryParameter::class);
321+
$this->assertInstanceOf(QueryParameter::class, $filterParam);
322+
$this->assertSame('filter_active', $filterParam->getKey());
323+
$this->assertSame('isActive', $filterParam->getProperty());
324+
$this->assertSame('Filter by active status', $filterParam->getDescription());
325+
}
326+
327+
public function testQueryParameterFromPropertyAttributeThrowsExceptionWhenPropertyMismatch(): void
328+
{
329+
$this->expectException(RuntimeException::class);
330+
$this->expectExceptionMessage('Parameter attribute on property "name" must target itself or have no explicit property. Got "property: \'description\'" instead.');
331+
332+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
333+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description']));
334+
335+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
336+
$propertyMetadata->method('create')->willReturn(
337+
new ApiProperty(readable: true),
338+
);
339+
340+
$filterLocator = $this->createStub(ContainerInterface::class);
341+
$filterLocator->method('has')->willReturn(false);
342+
343+
$parameterFactory = new ParameterResourceMetadataCollectionFactory(
344+
$nameCollection,
345+
$propertyMetadata,
346+
new AttributesResourceMetadataCollectionFactory(),
347+
$filterLocator
348+
);
349+
350+
$parameterFactory->create(ParameterOnPropertiesMismatchPropertyException::class);
351+
}
352+
353+
public function testQueryParameterFromPropertyAttributeThrowsExceptionWhenPropertiesMismatch(): void
354+
{
355+
$this->expectException(RuntimeException::class);
356+
$this->expectExceptionMessage('Parameter attribute on property "name" must target itself or have no explicit properties. Got "properties: [description]" instead.');
357+
358+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
359+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description']));
360+
361+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
362+
$propertyMetadata->method('create')->willReturn(
363+
new ApiProperty(readable: true),
364+
);
365+
366+
$filterLocator = $this->createStub(ContainerInterface::class);
367+
$filterLocator->method('has')->willReturn(false);
368+
369+
$parameterFactory = new ParameterResourceMetadataCollectionFactory(
370+
$nameCollection,
371+
$propertyMetadata,
372+
new AttributesResourceMetadataCollectionFactory(),
373+
$filterLocator
374+
);
375+
376+
$parameterFactory->create(ParameterOnPropertiesMismatchPropertiesException::class);
377+
}
378+
379+
public function testQueryParameterFromPropertyAttributeThrowsExceptionWhenPropertiesHasMultipleWithoutSelf(): void
380+
{
381+
$this->expectException(RuntimeException::class);
382+
$this->expectExceptionMessage('Parameter attribute on property "name" must target itself or have no explicit properties. Got "properties: [description, active]" instead.');
383+
384+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
385+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'active']));
386+
387+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
388+
$propertyMetadata->method('create')->willReturn(
389+
new ApiProperty(readable: true),
390+
);
391+
392+
$filterLocator = $this->createStub(ContainerInterface::class);
393+
$filterLocator->method('has')->willReturn(false);
394+
395+
$parameterFactory = new ParameterResourceMetadataCollectionFactory(
396+
$nameCollection,
397+
$propertyMetadata,
398+
new AttributesResourceMetadataCollectionFactory(),
399+
$filterLocator
400+
);
401+
402+
$parameterFactory->create(ParameterOnPropertiesMismatchMultiplePropertiesException::class);
403+
}
404+
405+
public function testQueryParameterFromPropertyAttributePropertiesSingleCorrectProperty(): void
406+
{
407+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
408+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description']));
409+
410+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
411+
$propertyMetadata->method('create')->willReturn(
412+
new ApiProperty(readable: true),
413+
);
414+
415+
$filterLocator = $this->createStub(ContainerInterface::class);
416+
$filterLocator->method('has')->willReturn(false);
417+
418+
$parameterFactory = new ParameterResourceMetadataCollectionFactory(
419+
$nameCollection,
420+
$propertyMetadata,
421+
new AttributesResourceMetadataCollectionFactory(),
422+
$filterLocator
423+
);
424+
425+
$resourceMetadataCollection = $parameterFactory->create(ParameterOnPropertiesSingleCorrectProperty::class);
426+
$operation = $resourceMetadataCollection->getOperation(forceCollection: true);
427+
$parameters = $operation->getParameters();
428+
429+
$this->assertInstanceOf(Parameters::class, $parameters);
430+
431+
$this->assertTrue($parameters->has('search'));
432+
$searchParam = $parameters->get('search', QueryParameter::class);
433+
$this->assertInstanceOf(QueryParameter::class, $searchParam);
434+
$this->assertSame('search', $searchParam->getKey());
435+
$this->assertSame('name', $searchParam->getProperty());
436+
$this->assertSame(['name'], $searchParam->getProperties());
437+
}
438+
439+
public function testQueryParameterFromPropertyAttributePropertiesHasMultipleIncludingSelf(): void
440+
{
441+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
442+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description']));
443+
444+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
445+
$propertyMetadata->method('create')->willReturn(
446+
new ApiProperty(readable: true),
447+
);
448+
449+
$filterLocator = $this->createStub(ContainerInterface::class);
450+
$filterLocator->method('has')->willReturn(false);
451+
452+
$parameterFactory = new ParameterResourceMetadataCollectionFactory(
453+
$nameCollection,
454+
$propertyMetadata,
455+
new AttributesResourceMetadataCollectionFactory(),
456+
$filterLocator
457+
);
458+
459+
$resourceMetadataCollection = $parameterFactory->create(ParameterOnPropertiesMultiplePropertiesIncludingSelf::class);
460+
$operation = $resourceMetadataCollection->getOperation(forceCollection: true);
461+
$parameters = $operation->getParameters();
462+
463+
$this->assertInstanceOf(Parameters::class, $parameters);
464+
465+
$this->assertTrue($parameters->has('search'));
466+
$searchParam = $parameters->get('search', QueryParameter::class);
467+
$this->assertInstanceOf(QueryParameter::class, $searchParam);
468+
$this->assertSame('search', $searchParam->getKey());
469+
$this->assertSame('name', $searchParam->getProperty());
470+
$this->assertSame(['name'], $searchParam->getProperties());
471+
}
472+
285473
public function testNestedPropertyWithNameConverter(): void
286474
{
287475
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
@@ -537,3 +725,60 @@ class NestedTestVariation
537725
public ?int $id = null;
538726
public ?string $variantName = null;
539727
}
728+
729+
#[ApiResource]
730+
class ParameterOnProperties
731+
{
732+
#[QueryParameter(key: 'search', description: 'Search by name')]
733+
public string $name = '';
734+
735+
#[QueryParameter(key: 'filter_active', description: 'Filter by active status')]
736+
public bool $isActive = true;
737+
}
738+
739+
#[ApiResource]
740+
class ParameterOnPropertiesMismatchPropertyException
741+
{
742+
#[QueryParameter(key: 'search', property: 'description')]
743+
public string $name = '';
744+
745+
public string $description = '';
746+
}
747+
748+
#[ApiResource]
749+
class ParameterOnPropertiesMismatchPropertiesException
750+
{
751+
#[QueryParameter(key: 'search', properties: ['description'])]
752+
public string $name = '';
753+
754+
public string $description = '';
755+
}
756+
757+
#[ApiResource]
758+
class ParameterOnPropertiesMismatchMultiplePropertiesException
759+
{
760+
#[QueryParameter(key: 'search', properties: ['description', 'active'])]
761+
public string $name = '';
762+
763+
public string $description = '';
764+
765+
public bool $active = true;
766+
}
767+
768+
#[ApiResource]
769+
class ParameterOnPropertiesSingleCorrectProperty
770+
{
771+
#[QueryParameter(key: 'search', properties: ['name'])]
772+
public string $name = '';
773+
774+
public string $description = '';
775+
}
776+
777+
#[ApiResource]
778+
class ParameterOnPropertiesMultiplePropertiesIncludingSelf
779+
{
780+
#[QueryParameter(key: 'search', properties: ['description', 'name'])]
781+
public string $name = '';
782+
783+
public string $description = '';
784+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\QueryParameter;
21+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
22+
23+
#[ODM\Document]
24+
#[ApiResource(
25+
uriTemplate: 'parameter_on_properties',
26+
operations: [
27+
new GetCollection(),
28+
new Get(),
29+
]
30+
)]
31+
class ParameterOnProperties
32+
{
33+
#[ODM\Id]
34+
private ?string $id = null;
35+
36+
#[ODM\Field(type: 'string')]
37+
#[QueryParameter(key: 'qname', filter: new PartialSearchFilter())]
38+
private string $name = '';
39+
40+
#[ODM\Field(type: 'string', nullable: true)]
41+
private ?string $description = null;
42+
43+
public function __construct(string $name = '', ?string $description = null)
44+
{
45+
$this->name = $name;
46+
$this->description = $description;
47+
}
48+
49+
public function getId(): ?string
50+
{
51+
return $this->id;
52+
}
53+
54+
public function getName(): string
55+
{
56+
return $this->name;
57+
}
58+
59+
public function setName(string $name): self
60+
{
61+
$this->name = $name;
62+
63+
return $this;
64+
}
65+
66+
public function getDescription(): ?string
67+
{
68+
return $this->description;
69+
}
70+
71+
public function setDescription(?string $description): self
72+
{
73+
$this->description = $description;
74+
75+
return $this;
76+
}
77+
}

0 commit comments

Comments
 (0)