diff --git a/src/Metadata/HeaderParameter.php b/src/Metadata/HeaderParameter.php index 8f8a30a9c6..59028d9f43 100644 --- a/src/Metadata/HeaderParameter.php +++ b/src/Metadata/HeaderParameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Metadata; -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE | \Attribute::TARGET_PROPERTY)] class HeaderParameter extends Parameter implements HeaderParameterInterface { } diff --git a/src/Metadata/QueryParameter.php b/src/Metadata/QueryParameter.php index 56fcc4babe..0cda72671c 100644 --- a/src/Metadata/QueryParameter.php +++ b/src/Metadata/QueryParameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Metadata; -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE | \Attribute::TARGET_PROPERTY)] class QueryParameter extends Parameter implements QueryParameterInterface { } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 5c0db818d7..0de9882ef5 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -234,6 +234,10 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $propertyNames = $properties = []; $parameters = $operation->getParameters() ?? new Parameters(); + foreach ($this->createParametersFromAttributes($operation) as $key => $parameter) { + $parameters->add($key, $parameter); + } + // First loop we look for the :property placeholder and replace its key foreach ($parameters as $key => $parameter) { if (!str_contains($key, ':property')) { @@ -469,4 +473,38 @@ private function getFilterInstance(object|string|null $filter): ?object return $this->filterLocator->get($filter); } + + private function createParametersFromAttributes(Operation $operation): Parameters + { + $parameters = new Parameters(); + + if (null === $resourceClass = $operation->getClass()) { + return $parameters; + } + + foreach ((new \ReflectionClass($resourceClass))->getProperties() as $reflectionProperty) { + foreach ($reflectionProperty->getAttributes(Parameter::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $parameter = $attribute->newInstance(); + + $propertyName = $reflectionProperty->getName(); + $key = $parameter->getKey() ?? $propertyName; + + if (null === $parameterPropertyName = $parameter->getProperty()) { + $parameter = $parameter->withProperty($propertyName); + } elseif ($parameterPropertyName !== $propertyName) { + throw new RuntimeException(\sprintf('Parameter attribute on property "%s" must target itself or have no explicit property. Got "property: \'%s\'" instead.', $propertyName, $parameterPropertyName)); + } + + if (null === ($parameterProperties = $parameter->getProperties()) || \in_array($propertyName, $parameterProperties, true)) { + $parameter = $parameter->withProperties([$propertyName]); + } elseif (!\in_array($propertyName, $parameterProperties, true)) { + throw new RuntimeException(\sprintf('Parameter attribute on property "%s" must target itself or have no explicit properties. Got "properties: [%s]" instead.', $propertyName, implode(', ', $parameterProperties))); + } + + $parameters->add($key, $parameter); + } + } + + return $parameters; + } } diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php index 612e197e41..95d525e6ab 100644 --- a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php @@ -15,8 +15,10 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -282,6 +284,380 @@ public function testParameterFactoryWithLimitedProperties(): void $this->assertSame(['name'], $param->getProperties()); } + public function testQueryParameterFromPropertyAttributes(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'isActive'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resourceMetadataCollection = $parameterFactory->create(ParameterOnProperties::class); + $operation = $resourceMetadataCollection->getOperation(forceCollection: true); + $parameters = $operation->getParameters(); + + $this->assertInstanceOf(Parameters::class, $parameters); + + $this->assertTrue($parameters->has('search')); + $searchParam = $parameters->get('search', QueryParameter::class); + $this->assertInstanceOf(QueryParameter::class, $searchParam); + $this->assertSame('search', $searchParam->getKey()); + $this->assertSame('name', $searchParam->getProperty()); + $this->assertSame('Search by name', $searchParam->getDescription()); + + $this->assertTrue($parameters->has('filter_active')); + $filterParam = $parameters->get('filter_active', QueryParameter::class); + $this->assertInstanceOf(QueryParameter::class, $filterParam); + $this->assertSame('filter_active', $filterParam->getKey()); + $this->assertSame('isActive', $filterParam->getProperty()); + $this->assertSame('Filter by active status', $filterParam->getDescription()); + } + + public function testQueryParameterFromPropertyAttributeThrowsExceptionWhenPropertyMismatch(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Parameter attribute on property "name" must target itself or have no explicit property. Got "property: \'description\'" instead.'); + + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $parameterFactory->create(ParameterOnPropertiesMismatchPropertyException::class); + } + + public function testQueryParameterFromPropertyAttributeThrowsExceptionWhenPropertiesMismatch(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Parameter attribute on property "name" must target itself or have no explicit properties. Got "properties: [description]" instead.'); + + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $parameterFactory->create(ParameterOnPropertiesMismatchPropertiesException::class); + } + + public function testQueryParameterFromPropertyAttributeThrowsExceptionWhenPropertiesHasMultipleWithoutSelf(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Parameter attribute on property "name" must target itself or have no explicit properties. Got "properties: [description, active]" instead.'); + + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'active'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $parameterFactory->create(ParameterOnPropertiesMismatchMultiplePropertiesException::class); + } + + public function testQueryParameterFromPropertyAttributePropertiesSingleCorrectProperty(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resourceMetadataCollection = $parameterFactory->create(ParameterOnPropertiesSingleCorrectProperty::class); + $operation = $resourceMetadataCollection->getOperation(forceCollection: true); + $parameters = $operation->getParameters(); + + $this->assertInstanceOf(Parameters::class, $parameters); + + $this->assertTrue($parameters->has('search')); + $searchParam = $parameters->get('search', QueryParameter::class); + $this->assertInstanceOf(QueryParameter::class, $searchParam); + $this->assertSame('search', $searchParam->getKey()); + $this->assertSame('name', $searchParam->getProperty()); + $this->assertSame(['name'], $searchParam->getProperties()); + } + + public function testQueryParameterFromPropertyAttributePropertiesHasMultipleIncludingSelf(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resourceMetadataCollection = $parameterFactory->create(ParameterOnPropertiesMultiplePropertiesIncludingSelf::class); + $operation = $resourceMetadataCollection->getOperation(forceCollection: true); + $parameters = $operation->getParameters(); + + $this->assertInstanceOf(Parameters::class, $parameters); + + $this->assertTrue($parameters->has('search')); + $searchParam = $parameters->get('search', QueryParameter::class); + $this->assertInstanceOf(QueryParameter::class, $searchParam); + $this->assertSame('search', $searchParam->getKey()); + $this->assertSame('name', $searchParam->getProperty()); + $this->assertSame(['name'], $searchParam->getProperties()); + } + + public function testHeaderParameterFromPropertyAttributes(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'authToken', 'token'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resourceMetadataCollection = $parameterFactory->create(HeaderParameterOnPropertiesTest::class); + $operation = $resourceMetadataCollection->getOperation(forceCollection: true); + $parameters = $operation->getParameters(); + + $this->assertInstanceOf(Parameters::class, $parameters); + + $this->assertTrue($parameters->has('X-Authorization', HeaderParameter::class)); + $authParam = $parameters->get('X-Authorization', HeaderParameter::class); + $this->assertInstanceOf(HeaderParameter::class, $authParam); + $this->assertSame('X-Authorization', $authParam->getKey()); + $this->assertSame('authToken', $authParam->getProperty()); + $this->assertSame('Authorization header', $authParam->getDescription()); + + $this->assertTrue($parameters->has('X-Token', HeaderParameter::class)); + $tokenParam = $parameters->get('X-Token', HeaderParameter::class); + $this->assertInstanceOf(HeaderParameter::class, $tokenParam); + $this->assertSame('X-Token', $tokenParam->getKey()); + $this->assertSame('token', $tokenParam->getProperty()); + $this->assertSame('API Token header', $tokenParam->getDescription()); + } + + public function testHeaderParameterFromPropertyAttributeThrowsExceptionWhenPropertyMismatch(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Parameter attribute on property "authToken" must target itself or have no explicit property. Got "property: \'token\'" instead.'); + + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'authToken', 'token'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $parameterFactory->create(HeaderParameterOnPropertiesMismatchException::class); + } + + public function testHeaderParameterFromPropertyAttributeThrowsExceptionWhenPropertiesMismatch(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Parameter attribute on property "authToken" must target itself or have no explicit properties. Got "properties: [token]" instead.'); + + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'authToken', 'token'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $parameterFactory->create(HeaderParameterOnPropertiesMismatchPropertiesException::class); + } + + public function testHeaderParameterFromPropertyAttributeThrowsExceptionWhenPropertiesHasMultipleWithoutSelf(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Parameter attribute on property "authToken" must target itself or have no explicit properties. Got "properties: [token, token2]" instead.'); + + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'authToken', 'token', 'token2'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $parameterFactory->create(HeaderParameterOnPropertiesMismatchMultiplePropertiesException::class); + } + + public function testHeaderParameterFromPropertyAttributePropertiesSingleCorrectProperty(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'authToken', 'token'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resourceMetadataCollection = $parameterFactory->create(HeaderParameterOnPropertiesSingleCorrectProperty::class); + $operation = $resourceMetadataCollection->getOperation(forceCollection: true); + $parameters = $operation->getParameters(); + + $this->assertInstanceOf(Parameters::class, $parameters); + + $this->assertTrue($parameters->has('X-Authorization', HeaderParameter::class)); + $authParam = $parameters->get('X-Authorization', HeaderParameter::class); + $this->assertInstanceOf(HeaderParameter::class, $authParam); + $this->assertSame('X-Authorization', $authParam->getKey()); + $this->assertSame('authToken', $authParam->getProperty()); + $this->assertSame(['authToken'], $authParam->getProperties()); + } + + public function testHeaderParameterFromPropertyAttributePropertiesHasMultipleIncludingSelf(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'authToken', 'token'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resourceMetadataCollection = $parameterFactory->create(HeaderParameterOnPropertiesMultiplePropertiesIncludingSelf::class); + $operation = $resourceMetadataCollection->getOperation(forceCollection: true); + $parameters = $operation->getParameters(); + + $this->assertInstanceOf(Parameters::class, $parameters); + + $this->assertTrue($parameters->has('X-Authorization', HeaderParameter::class)); + $authParam = $parameters->get('X-Authorization', HeaderParameter::class); + $this->assertInstanceOf(HeaderParameter::class, $authParam); + $this->assertSame('X-Authorization', $authParam->getKey()); + $this->assertSame('authToken', $authParam->getProperty()); + $this->assertSame(['authToken'], $authParam->getProperties()); + } + public function testNestedPropertyWithNameConverter(): void { $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); @@ -537,3 +913,117 @@ class NestedTestVariation public ?int $id = null; public ?string $variantName = null; } + +#[ApiResource] +class ParameterOnProperties +{ + #[QueryParameter(key: 'search', description: 'Search by name')] + public string $name = ''; + + #[QueryParameter(key: 'filter_active', description: 'Filter by active status')] + public bool $isActive = true; +} + +#[ApiResource] +class ParameterOnPropertiesMismatchPropertyException +{ + #[QueryParameter(key: 'search', property: 'description')] + public string $name = ''; + + public string $description = ''; +} + +#[ApiResource] +class ParameterOnPropertiesMismatchPropertiesException +{ + #[QueryParameter(key: 'search', properties: ['description'])] + public string $name = ''; + + public string $description = ''; +} + +#[ApiResource] +class ParameterOnPropertiesMismatchMultiplePropertiesException +{ + #[QueryParameter(key: 'search', properties: ['description', 'active'])] + public string $name = ''; + + public string $description = ''; + + public bool $active = true; +} + +#[ApiResource] +class ParameterOnPropertiesSingleCorrectProperty +{ + #[QueryParameter(key: 'search', properties: ['name'])] + public string $name = ''; + + public string $description = ''; +} + +#[ApiResource] +class ParameterOnPropertiesMultiplePropertiesIncludingSelf +{ + #[QueryParameter(key: 'search', properties: ['description', 'name'])] + public string $name = ''; + + public string $description = ''; +} + +#[ApiResource] +class HeaderParameterOnPropertiesTest +{ + #[HeaderParameter(key: 'X-Authorization', description: 'Authorization header')] + public string $authToken = ''; + + #[HeaderParameter(key: 'X-Token', description: 'API Token header')] + public string $token = ''; +} + +#[ApiResource] +class HeaderParameterOnPropertiesMismatchException +{ + #[HeaderParameter(key: 'X-Authorization', property: 'token')] + public string $authToken = ''; + + public string $token = ''; +} + +#[ApiResource] +class HeaderParameterOnPropertiesSingleCorrectProperty +{ + #[HeaderParameter(key: 'X-Authorization', properties: ['authToken'])] + public string $authToken = ''; + + public string $token = ''; +} + +#[ApiResource] +class HeaderParameterOnPropertiesMultiplePropertiesIncludingSelf +{ + #[HeaderParameter(key: 'X-Authorization', properties: ['token', 'authToken'])] + public string $authToken = ''; + + public string $token = ''; +} + +#[ApiResource] +class HeaderParameterOnPropertiesMismatchPropertiesException +{ + #[HeaderParameter(key: 'X-Authorization', properties: ['token'])] + public string $authToken = ''; + + public string $token = ''; +} + +#[ApiResource] +class HeaderParameterOnPropertiesMismatchMultiplePropertiesException +{ + #[HeaderParameter(key: 'X-Authorization', properties: ['token', 'token2'])] + public string $authToken = ''; + + public string $token = ''; + + public string $token2 = ''; +} diff --git a/tests/Fixtures/TestBundle/Document/ParameterOnProperties.php b/tests/Fixtures/TestBundle/Document/ParameterOnProperties.php new file mode 100644 index 0000000000..c67071d893 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ParameterOnProperties.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + uriTemplate: 'parameter_on_properties', + operations: [ + new GetCollection(), + new Get(), + ] +)] +class ParameterOnProperties +{ + #[ODM\Id] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + #[QueryParameter(key: 'qname', filter: new PartialSearchFilter())] + private string $name = ''; + + #[ODM\Field(type: 'string', nullable: true)] + private ?string $description = null; + + public function __construct(string $name = '', ?string $description = null) + { + $this->name = $name; + $this->description = $description; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/ParameterOnPropertiesWithHeaderParameter.php b/tests/Fixtures/TestBundle/Document/ParameterOnPropertiesWithHeaderParameter.php new file mode 100644 index 0000000000..83b4913eda --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ParameterOnPropertiesWithHeaderParameter.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + uriTemplate: 'parameter_on_properties_with_header_parameter', + operations: [ + new GetCollection(), + new Get(), + ] +)] +class ParameterOnPropertiesWithHeaderParameter +{ + #[ODM\Id] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + #[HeaderParameter(key: 'X-Authorization', description: 'Authorization header')] + public string $authToken = ''; + + public function __construct(string $authToken = '') + { + $this->authToken = $authToken; + } + + public function getId(): ?string + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ParameterOnProperties.php b/tests/Fixtures/TestBundle/Entity/ParameterOnProperties.php new file mode 100644 index 0000000000..f1a4cb08a4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ParameterOnProperties.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + uriTemplate: 'parameter_on_properties', + operations: [ + new GetCollection(), + new Get(), + ] +)] +class ParameterOnProperties +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + #[QueryParameter(key: 'qname', filter: new PartialSearchFilter())] + private string $name = ''; + + #[ORM\Column(type: 'string', nullable: true)] + private ?string $description = null; + + public function __construct(string $name = '', ?string $description = null) + { + $this->name = $name; + $this->description = $description; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ParameterOnPropertiesWithHeaderParameter.php b/tests/Fixtures/TestBundle/Entity/ParameterOnPropertiesWithHeaderParameter.php new file mode 100644 index 0000000000..654611f951 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ParameterOnPropertiesWithHeaderParameter.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + uriTemplate: 'parameter_on_properties_with_header_parameter', + operations: [ + new GetCollection(), + new Get(), + ] +)] +class ParameterOnPropertiesWithHeaderParameter +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', name: 'auth_token')] + #[HeaderParameter(key: 'X-Authorization', description: 'Authorization header')] + public string $authToken = ''; + + public function __construct(string $authToken = '') + { + $this->authToken = $authToken; + } + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index c15809667e..22939f5229 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -39,6 +39,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\JsonSchemaResourceRelated; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NoCollectionDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OverriddenOperationDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ParameterOnProperties; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ParameterOnPropertiesWithHeaderParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; @@ -104,6 +106,8 @@ public static function getResources(): array WrappedResponseEntity::class, ParentAttribute::class, ChildAttribute::class, + ParameterOnProperties::class, + ParameterOnPropertiesWithHeaderParameter::class, ]; } @@ -649,4 +653,63 @@ public function testOpenApiSchemaWithNormalizationAttributes(): void $this->assertArrayNotHasKey('hiddenData', $childProperties); $this->assertArrayNotHasKey('id', $childProperties); } + + public function testOpenApiParameterOnProperties(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $this->assertArrayHasKey('paths', $json); + $this->assertArrayHasKey('/parameter_on_properties', $json['paths']); + $this->assertArrayHasKey('get', $json['paths']['/parameter_on_properties']); + $this->assertArrayHasKey('parameters', $json['paths']['/parameter_on_properties']['get']); + + $parameters = $json['paths']['/parameter_on_properties']['get']['parameters']; + + $this->assertCount(1, $parameters); + $qnameParameter = $parameters[0]; + + $this->assertNotNull($qnameParameter); + $this->assertSame('qname[]', $qnameParameter['name']); + $this->assertSame('query', $qnameParameter['in']); + $this->assertSame('ParameterOnProperties qname', $qnameParameter['description']); + $this->assertFalse($qnameParameter['required']); + $this->assertFalse($qnameParameter['deprecated']); + $this->assertSame('array', $qnameParameter['schema']['type']); + $this->assertSame('string', $qnameParameter['schema']['items']['type']); + $this->assertSame('deepObject', $qnameParameter['style']); + $this->assertTrue($qnameParameter['explode']); + } + + public function testOpenApiParameterOnPropertiesWithHeaderParameter(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $this->assertArrayHasKey('paths', $json); + $this->assertArrayHasKey('/parameter_on_properties_with_header_parameter', $json['paths']); + $this->assertArrayHasKey('get', $json['paths']['/parameter_on_properties_with_header_parameter']); + $this->assertArrayHasKey('parameters', $json['paths']['/parameter_on_properties_with_header_parameter']['get']); + + $parameters = $json['paths']['/parameter_on_properties_with_header_parameter']['get']['parameters']; + + $this->assertCount(1, $parameters); + $authParameter = $parameters[0]; + + $this->assertNotNull($authParameter); + $this->assertSame('X-Authorization', $authParameter['name']); + $this->assertSame('header', $authParameter['in']); + $this->assertSame('Authorization header', $authParameter['description']); + $this->assertFalse($authParameter['required']); + $this->assertFalse($authParameter['deprecated']); + $this->assertSame('string', $authParameter['schema']['type']); + } } diff --git a/tests/Functional/Parameters/ParameterOnPropertiesTest.php b/tests/Functional/Parameters/ParameterOnPropertiesTest.php new file mode 100644 index 0000000000..cd0d5c04cc --- /dev/null +++ b/tests/Functional/Parameters/ParameterOnPropertiesTest.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ParameterOnProperties as DocumentParameterOnProperties; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ParameterOnPropertiesWithHeaderParameter as DocumentParameterOnPropertiesWithHeaderParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ParameterOnProperties; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ParameterOnPropertiesWithHeaderParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class ParameterOnPropertiesTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ParameterOnProperties::class, ParameterOnPropertiesWithHeaderParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentParameterOnProperties::class, DocumentParameterOnPropertiesWithHeaderParameter::class] + : [ParameterOnProperties::class, ParameterOnPropertiesWithHeaderParameter::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + public function testQueryParameterOnProperty(): void + { + $response = self::createClient()->request('GET', 'parameter_on_properties?qname=oo'); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + + $this->assertArrayHasKey('hydra:member', $responseData); + $members = $responseData['hydra:member']; + + $this->assertCount(2, $members); + $this->assertSame('foo', $members[0]['name']); + $this->assertSame('qoox', $members[1]['name']); + } + + public function testHeaderParameterOnProperty(): void + { + $response = self::createClient()->request('GET', 'parameter_on_properties_with_header_parameter', [ + 'headers' => [ + 'X-Authorization' => 'Bearer token123', + ], + ]); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + + $this->assertArrayHasKey('hydra:member', $responseData); + $members = $responseData['hydra:member']; + + $this->assertCount(1, $members); + $this->assertSame('test-auth', $members[0]['authToken']); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $parameterOnPropertiesClass = $this->isMongoDB() ? DocumentParameterOnProperties::class : ParameterOnProperties::class; + + $manager->persist(new $parameterOnPropertiesClass('foo', 'bar')); + $manager->persist(new $parameterOnPropertiesClass('baz', 'qux')); + $manager->persist(new $parameterOnPropertiesClass('qoox', 'corge')); + + $headerParameterClass = $this->isMongoDB() ? DocumentParameterOnPropertiesWithHeaderParameter::class : ParameterOnPropertiesWithHeaderParameter::class; + + $manager->persist(new $headerParameterClass('test-auth')); + + $manager->flush(); + } +}