From ea27046477e7973223576f0d7d2c271a6dac1a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Wed, 18 Sep 2024 16:59:54 +0200 Subject: [PATCH 1/8] operation openapi attribute support --- .../ApiPlatformCoreAttributeGenerator.php | 81 +++++++++- tests/Command/GenerateCommandTest.php | 139 ++++++++++++++++++ tests/config/openapi-operation-property.yaml | 106 +++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 tests/config/openapi-operation-property.yaml diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index 35e4d7e8..ec51909e 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -23,6 +23,9 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\OpenApi\Model\Operation; +use ApiPlatform\OpenApi\Model\Parameter; +use ApiPlatform\OpenApi\Model\Response; use ApiPlatform\SchemaGenerator\Model\Attribute; use ApiPlatform\SchemaGenerator\Model\Class_; use ApiPlatform\SchemaGenerator\Model\Property; @@ -39,6 +42,21 @@ */ final class ApiPlatformCoreAttributeGenerator extends AbstractAttributeGenerator { + /** + * Hints for not typed array parameters. + */ + private const PRAMETER_TYPE_HINTS = [ + Operation::class => [ + 'responses' => Response::class.'[]', + 'parameters' => Parameter::class.'[]', + ], + ]; + + /** + * @var array> + */ + private static array $parameterTypes = []; + public function generateClassAttributes(Class_ $class): array { if ($class->hasChild || $class->isEnum()) { @@ -84,7 +102,21 @@ public function generateClassAttributes(Class_ $class): array $operationMetadataClass = $methodConfig['class']; unset($methodConfig['class']); } - + if (\is_array($methodConfig['openapi'] ?? null)) { + $methodConfig['openapi'] = Literal::new( + 'Operation', + self::extractParameters(Operation::class, $methodConfig['openapi']) + ); + $class->addUse(new Use_(Operation::class)); + array_walk_recursive( + self::$parameterTypes, + function (?string $type) use ($class) { + if (null !== $type) { + $class->addUse(new Use_(str_replace('[]', '', $type))); + } + } + ); + } $arguments['operations'][] = new Literal(sprintf('new %s(...?:)', $operationMetadataClass, ), [$methodConfig ?? []]); @@ -95,6 +127,53 @@ public function generateClassAttributes(Class_ $class): array return [new Attribute('ApiResource', $arguments)]; } + /** + * @param class-string $type + * @param mixed[] $values + * + * @return mixed[] + */ + private static function extractParameters(string $type, array $values): array + { + $types = self::$parameterTypes[$type] ??= + (static::PRAMETER_TYPE_HINTS[$type] ?? []) + array_reduce( + (new \ReflectionClass($type))->getConstructor()?->getParameters() ?? [], + static fn (array $types, \ReflectionParameter $refl) => $types + [ + $refl->getName() => $refl->getType() instanceof \ReflectionNamedType + && !$refl->getType()->isBuiltin() + ? $refl->getType()->getName() + : null, + ], + [] + ); + + $parameters = array_intersect_key($values, $types); + foreach ($parameters as $name => $parameter) { + $type = $types[$name]; + if (null !== $type && \is_array($parameter)) { + $isArrayType = str_ends_with($type, '[]'); + $type = $isArrayType ? substr($type, 0, -2) : $type; + $shortName = (new \ReflectionClass($type))->getShortName(); + $parameters[$name] = $isArrayType + ? array_map( + static fn (array $values) => Literal::new( + $shortName, + self::extractParameters($type, $values) + ), + $parameter + ) + : Literal::new( + $shortName, + \ArrayObject::class === $type + ? [$parameter] + : self::extractParameters($type, $parameter) + ); + } + } + + return $parameters; + } + /** * Verifies that the operations' config is valid. * diff --git a/tests/Command/GenerateCommandTest.php b/tests/Command/GenerateCommandTest.php index 53bd47d4..46d6a524 100644 --- a/tests/Command/GenerateCommandTest.php +++ b/tests/Command/GenerateCommandTest.php @@ -539,6 +539,145 @@ class Page extends Object_ self::assertFalse($this->fs->exists("$outputDir/App/Entity/Travel.php")); } + public function testOpenapiOperationProperty(): void + { + $outputDir = __DIR__.'/../../build/openapi-operation-property'; + $config = __DIR__.'/../config/openapi-operation-property.yaml'; + + $this->fs->mkdir($outputDir); + + $commandTester = new CommandTester(new GenerateCommand()); + $this->assertEquals(0, $commandTester->execute(['output' => $outputDir, 'config' => $config])); + $source = file_get_contents("$outputDir/App/Entity/Saml.php"); + + $this->assertStringContainsString(<<<'PHP' +use ApiPlatform\OpenApi\Model\Operation; +use ApiPlatform\OpenApi\Model\Parameter; +use ApiPlatform\OpenApi\Model\RequestBody; +use ApiPlatform\OpenApi\Model\Response; +PHP + , $source); + + $this->assertStringContainsString(<<<'PHP' +#[ApiResource( + shortName: 'Saml', + types: ['https://schema.org/Thing'], + operations: [ + new Get( + name: 'login', + uriTemplate: '/saml/{id}/login', + controller: 'App\Controller\SamlController::login', + openapi: new Operation( + tags: ['Auth'], + summary: 'SAML authentication.', + description: 'SAML authentication.', + responses: [ + 302 => new Response( + description: 'Initialization successful.', + headers: new \ArrayObject([ + 'Location' => [ + 'required' => true, + 'description' => 'SAML login page redirection.', + 'schema' => ['type' => 'string', 'format' => 'url'], + ], + ]), + ), + 403 => new Response(description: 'SAML disabled.'), + 404 => new Response(description: 'SAML not found.'), + ], + ), + ), + new Post( + name: 'acs', + uriTemplate: '/saml/{id}/acs', + controller: 'App\Controller\SamlController::acs', + inputFormats: ['urlencoded' => ['application/x-www-form-urlencoded']], + openapi: new Operation( + tags: ['Auth'], + summary: 'SAML ACS.', + description: 'SAML ACS.', + responses: [ + 302 => new Response( + description: 'Authentication successful.', + headers: new \ArrayObject([ + 'Location' => [ + 'required' => true, + 'description' => 'Redirection page.', + 'schema' => ['type' => 'string', 'format' => 'url'], + ], + ]), + ), + 401 => new Response(description: 'Authentication failed.'), + 403 => new Response(description: 'SAML disabled.'), + 404 => new Response(description: 'SAML not found.'), + ], + requestBody: new RequestBody( + required: true, + content: new \ArrayObject([ + 'application/x-www-form-urlencoded' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => ['SAMLResponse' => ['type' => 'string', 'description' => 'SAML login response.']], + ], + 'required' => ['SAMLResponse'], + ], + ]), + ), + ), + ), + new Get( + name: 'logout', + uriTemplate: '/saml/{id}/logout', + controller: 'App\Controller\SamlController::logout', + openapi: new Operation( + tags: ['Auth'], + summary: 'SAML logout.', + description: 'SAML logout.', + parameters: [ + new Parameter( + name: 'SAMLRequest', + in: 'query', + schema: ['type' => 'string'], + required: true, + description: 'SAML logout request.', + ), + new Parameter( + name: 'RelayState', + in: 'query', + schema: ['type' => 'string'], + required: false, + description: 'SAML logout response redirect URL.', + ), + new Parameter( + name: 'Signature', + in: 'query', + schema: ['type' => 'string'], + required: false, + description: 'SAML signature.', + ), + ], + responses: [ + 302 => new Response( + description: 'Logout successful.', + headers: new \ArrayObject([ + 'Location' => [ + 'required' => true, + 'description' => 'SAML logout response redirect URL.', + 'schema' => ['type' => 'string', 'format' => 'url'], + ], + ]), + ), + 403 => new Response(description: 'Logout failed.'), + 404 => new Response(description: 'SAML not found.'), + ], + ), + ), + ], +)] +PHP + , $source); + } + public function testGenerationWithoutConfigFileQuestion(): void { // No config file is given. diff --git a/tests/config/openapi-operation-property.yaml b/tests/config/openapi-operation-property.yaml new file mode 100644 index 00000000..d31c5ca6 --- /dev/null +++ b/tests/config/openapi-operation-property.yaml @@ -0,0 +1,106 @@ +types: + Saml: + operations: + login: + class: Get + name: "login" + uriTemplate: "/saml/{id}/login" + controller: "App\\Controller\\SamlController::login" + openapi: + tags: ["Auth"] + summary: "SAML authentication." + description: "SAML authentication." + responses: + 302: + description: "Initialization successful." + headers: + Location: + required: true + description: "SAML login page redirection." + schema: + type: "string" + format: "url" + 403: { description: "SAML disabled." } + 404: { description: "SAML not found." } + acs: + class: Post + name: "acs" + uriTemplate: "/saml/{id}/acs" + controller: "App\\Controller\\SamlController::acs" + inputFormats: + urlencoded: ['application/x-www-form-urlencoded'] + openapi: + tags: ["Auth"] + summary: "SAML ACS." + description: "SAML ACS." + responses: + 302: + description: "Authentication successful." + headers: + Location: + required: true + description: "Redirection page." + schema: + type: "string" + format: "url" + 401: { description: "Authentication failed."} + 403: { description: "SAML disabled." } + 404: { description: "SAML not found." } + requestBody: + required: true + content: + "application/x-www-form-urlencoded": + schema: + type: object + properties: + SAMLResponse: + type: string + description: "SAML login response." + required: ["SAMLResponse"] + logout: + class: Get + name: "logout" + uriTemplate: "/saml/{id}/logout" + controller: "App\\Controller\\SamlController::logout" + openapi: + tags: ["Auth"] + summary: "SAML logout." + description: "SAML logout." + parameters: + - name: "SAMLRequest" + in: "query" + schema: { type: "string" } + required: true + description: "SAML logout request." + - name: "RelayState" + in: "query" + schema: { type: "string" } + required: false + description: "SAML logout response redirect URL." + - name: "Signature" + in: "query" + schema: { type: "string" } + required: false + description: "SAML signature." + responses: + 302: + description: "Logout successful." + headers: + Location: + required: true + description: "SAML logout response redirect URL." + schema: + type: "string" + format: "url" + + 403: { description: "Logout failed." } + 404: { description: "SAML not found." } + attributes: + ApiResource: + shortName: "Saml" + properties: + name: + nullable: false + attributes: + ApiProperty: + iris: [ "https://schema.org/name" ] From c451ff49608fe4888e26d55f2515402c9beb267a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Thu, 27 Feb 2025 10:18:08 +0100 Subject: [PATCH 2/8] fix --- src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index aaaee582..2c6dbb94 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -118,7 +118,7 @@ function (?string $type) use ($class) { } ); } - $arguments['operations'][] = new Literal(sprintf('new %s(...?:)', + $arguments['operations'][] = new Literal(\sprintf('new %s(...?:)', $operationMetadataClass, ), [$methodConfig ?? []]); } From 6ff078355e60587f55e52c5bc9e2651b6c5f544e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Thu, 27 Feb 2025 11:56:04 +0100 Subject: [PATCH 3/8] arrow func return typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas --- src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index 2c6dbb94..091ac49f 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -157,7 +157,7 @@ private static function extractParameters(string $type, array $values): array $shortName = (new \ReflectionClass($type))->getShortName(); $parameters[$name] = $isArrayType ? array_map( - static fn (array $values) => Literal::new( + static fn (array $values): Literal => Literal::new( $shortName, self::extractParameters($type, $values) ), From 3e699941e2d644c0f8487dfd2d0ffabc0855b3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Thu, 27 Feb 2025 11:56:27 +0100 Subject: [PATCH 4/8] arrow func return typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas --- src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index 091ac49f..3b995d99 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -139,7 +139,7 @@ private static function extractParameters(string $type, array $values): array $types = self::$parameterTypes[$type] ??= (static::PRAMETER_TYPE_HINTS[$type] ?? []) + array_reduce( (new \ReflectionClass($type))->getConstructor()?->getParameters() ?? [], - static fn (array $types, \ReflectionParameter $refl) => $types + [ + static fn (array $types, \ReflectionParameter $refl): array => $types + [ $refl->getName() => $refl->getType() instanceof \ReflectionNamedType && !$refl->getType()->isBuiltin() ? $refl->getType()->getName() From 1736be222ec9ca810862c54f581f0df1a75a9ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Thu, 27 Feb 2025 11:56:41 +0100 Subject: [PATCH 5/8] arrow func return typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas --- src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index 3b995d99..8b7a44c5 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -111,7 +111,7 @@ public function generateClassAttributes(Class_ $class): array $class->addUse(new Use_(Operation::class)); array_walk_recursive( self::$parameterTypes, - function (?string $type) use ($class) { + function (?string $type): void use ($class) { if (null !== $type) { $class->addUse(new Use_(str_replace('[]', '', $type))); } From 336fab73a0611c3d98a026592a2ab1812821dbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Thu, 27 Feb 2025 11:57:13 +0100 Subject: [PATCH 6/8] styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas --- src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index 8b7a44c5..e5bb8820 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -118,6 +118,7 @@ function (?string $type): void use ($class) { } ); } + $arguments['operations'][] = new Literal(\sprintf('new %s(...?:)', $operationMetadataClass, ), [$methodConfig ?? []]); From 79524b5aee6f36306a581e60bfa8a786395056c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Thu, 27 Feb 2025 12:13:53 +0100 Subject: [PATCH 7/8] anon func return typing --- src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index e5bb8820..f1736fe7 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -111,7 +111,7 @@ public function generateClassAttributes(Class_ $class): array $class->addUse(new Use_(Operation::class)); array_walk_recursive( self::$parameterTypes, - function (?string $type): void use ($class) { + function (?string $type) use ($class): void { if (null !== $type) { $class->addUse(new Use_(str_replace('[]', '', $type))); } From fa0bfaf80ad21c831e4c7aaafe339647ec821cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Thu, 27 Feb 2025 15:55:41 +0100 Subject: [PATCH 8/8] no declarative code refacto --- .../ApiPlatformCoreAttributeGenerator.php | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index f1736fe7..05345c3d 100644 --- a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php +++ b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php @@ -148,28 +148,55 @@ private static function extractParameters(string $type, array $values): array ], [] ); + if (isset(self::$parameterTypes[$type])) { + $types = self::$parameterTypes[$type]; + } else { + $types = static::PRAMETER_TYPE_HINTS[$type] ?? []; + $parameterRefls = (new \ReflectionClass($type)) + ->getConstructor() + ?->getParameters() ?? []; + foreach ($parameterRefls as $refl) { + $paramName = $refl->getName(); + if (\array_key_exists($paramName, $types)) { + continue; + } + $paramType = $refl->getType(); + if ($paramType instanceof \ReflectionNamedType && !$paramType->isBuiltin()) { + $types[$paramName] = $paramType->getName(); + } else { + $types[$paramName] = null; + } + } + self::$parameterTypes[$type] = $types; + } $parameters = array_intersect_key($values, $types); foreach ($parameters as $name => $parameter) { $type = $types[$name]; - if (null !== $type && \is_array($parameter)) { - $isArrayType = str_ends_with($type, '[]'); - $type = $isArrayType ? substr($type, 0, -2) : $type; - $shortName = (new \ReflectionClass($type))->getShortName(); - $parameters[$name] = $isArrayType - ? array_map( - static fn (array $values): Literal => Literal::new( - $shortName, - self::extractParameters($type, $values) - ), - $parameter - ) - : Literal::new( + if (null === $type || !\is_array($parameter)) { + continue; + } + $isArrayType = str_ends_with($type, '[]'); + /** + * @var class-string + */ + $type = $isArrayType ? substr($type, 0, -2) : $type; + $shortName = (new \ReflectionClass($type))->getShortName(); + if ($isArrayType) { + $parameters[$name] = []; + foreach ($parameter as $key => $values) { + $parameters[$name][$key] = Literal::new( $shortName, - \ArrayObject::class === $type - ? [$parameter] - : self::extractParameters($type, $parameter) + self::extractParameters($type, $values) ); + } + } else { + $parameters[$name] = Literal::new( + $shortName, + \ArrayObject::class === $type + ? [$parameter] + : self::extractParameters($type, $parameter) + ); } }