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" ]