diff --git a/scoper.inc.php b/scoper.inc.php index c404d2b7..14df3fa0 100644 --- a/scoper.inc.php +++ b/scoper.inc.php @@ -17,7 +17,7 @@ ApiPlatform\Core\Annotation\ApiResource::class, ], 'patchers' => [ - function (string $filePath, string $prefix, string $content): string { + static function (string $filePath, string $prefix, string $content): string { // // PHP-CS-Fixer patch // @@ -35,7 +35,7 @@ function (string $filePath, string $prefix, string $content): string { // TODO: Temporary patch until the issue is fixed upstream // @link https://github.com/humbug/php-scoper/issues/285 - function (string $filePath, string $prefix, string $content): string { + static function (string $filePath, string $prefix, string $content): string { if (!str_contains($content, '@')) { return $content; } @@ -51,7 +51,7 @@ function (string $filePath, string $prefix, string $content): string { $content ); }, - function (string $filePath, string $prefix, string $content): string { + static function (string $filePath, string $prefix, string $content): string { if (!str_starts_with($filePath, 'src/AnnotationGenerator/')) { return $content; } diff --git a/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php b/src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php index f9bf1f00..b3df1a09 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()) { @@ -85,6 +103,22 @@ public function generateClassAttributes(Class_ $class): array 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, + static function (?string $type) use ($class): void { + if (null !== $type) { + $class->addUse(new Use_(str_replace('[]', '', $type))); + } + } + ); + } + $arguments['operations'][] = new Literal(\sprintf('new %s(...?:)', $operationMetadataClass, ), [$methodConfig ?? []]); @@ -95,6 +129,80 @@ 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): array => $types + [ + $refl->getName() => $refl->getType() instanceof \ReflectionNamedType + && !$refl->getType()->isBuiltin() + ? $refl->getType()->getName() + : null, + ], + [] + ); + 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)) { + 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, + self::extractParameters($type, $values) + ); + } + } else { + $parameters[$name] = Literal::new( + $shortName, + \ArrayObject::class === $type + ? [$parameter] + : self::extractParameters($type, $parameter) + ); + } + } + + return $parameters; + } + /** * Verifies that the operations' config is valid. * diff --git a/src/AttributeGenerator/ConfigurationAttributeGenerator.php b/src/AttributeGenerator/ConfigurationAttributeGenerator.php index e06c2dfb..9431fbef 100644 --- a/src/AttributeGenerator/ConfigurationAttributeGenerator.php +++ b/src/AttributeGenerator/ConfigurationAttributeGenerator.php @@ -31,7 +31,7 @@ public function generateClassAttributes(Class_ $class): array $getAttributesNames = static fn (array $config) => $config === [[]] ? [] - : array_unique(array_map(fn (array $v) => array_keys($v)[0], $config)); + : array_unique(array_map(static fn (array $v) => array_keys($v)[0], $config)); $typeAttributesNames = $getAttributesNames($typeAttributes); $vocabAttributesNames = $getAttributesNames($vocabAttributes); diff --git a/src/AttributeGenerator/DoctrineMongoDBAttributeGenerator.php b/src/AttributeGenerator/DoctrineMongoDBAttributeGenerator.php index ed0ba33e..8aecf29a 100644 --- a/src/AttributeGenerator/DoctrineMongoDBAttributeGenerator.php +++ b/src/AttributeGenerator/DoctrineMongoDBAttributeGenerator.php @@ -54,14 +54,14 @@ public function generateClassAttributes(Class_ $class): array $directChildren = array_merge($directChildren, array_filter($this->classes, fn (Class_ $childClass) => $parentName === $childClass->parent())); } $parentNames = array_keys($directChildren); - $childNames = array_merge($childNames, array_keys(array_filter($directChildren, fn (Class_ $childClass) => !$childClass->isAbstract))); + $childNames = array_merge($childNames, array_keys(array_filter($directChildren, static fn (Class_ $childClass) => !$childClass->isAbstract))); } $mapNames = array_merge([$class->name()], $childNames); $attributes[] = new Attribute('MongoDB\Document'); $attributes[] = new Attribute('MongoDB\InheritanceType', [\in_array($this->config['doctrine']['inheritanceType'], ['SINGLE_COLLECTION', 'COLLECTION_PER_CLASS', 'NONE'], true) ? $this->config['doctrine']['inheritanceType'] : 'SINGLE_COLLECTION']); $attributes[] = new Attribute('MongoDB\DiscriminatorField', ['discr']); - $attributes[] = new Attribute('MongoDB\DiscriminatorMap', [array_reduce($mapNames, fn (array $map, string $mapName) => $map + [u($mapName)->camel()->toString() => new Literal(\sprintf('%s::class', $mapName))], [])]); + $attributes[] = new Attribute('MongoDB\DiscriminatorMap', [array_reduce($mapNames, static fn (array $map, string $mapName) => $map + [u($mapName)->camel()->toString() => new Literal(\sprintf('%s::class', $mapName))], [])]); } else { $attributes[] = new Attribute('MongoDB\Document'); } diff --git a/src/AttributeGenerator/DoctrineOrmAttributeGenerator.php b/src/AttributeGenerator/DoctrineOrmAttributeGenerator.php index 72938ded..5e2c8e47 100644 --- a/src/AttributeGenerator/DoctrineOrmAttributeGenerator.php +++ b/src/AttributeGenerator/DoctrineOrmAttributeGenerator.php @@ -62,14 +62,14 @@ public function generateClassAttributes(Class_ $class): array $directChildren = array_merge($directChildren, array_filter($this->classes, fn (Class_ $childClass) => $parentName === $childClass->parent())); } $parentNames = array_keys($directChildren); - $childNames = array_merge($childNames, array_keys(array_filter($directChildren, fn (Class_ $childClass) => !$childClass->isAbstract))); + $childNames = array_merge($childNames, array_keys(array_filter($directChildren, static fn (Class_ $childClass) => !$childClass->isAbstract))); } $mapNames = array_merge([$class->name()], $childNames); $attributes[] = new Attribute('ORM\Entity'); $attributes[] = new Attribute('ORM\InheritanceType', [\in_array($this->config['doctrine']['inheritanceType'], ['JOINED', 'SINGLE_TABLE', 'TABLE_PER_CLASS', 'NONE'], true) ? $this->config['doctrine']['inheritanceType'] : 'JOINED']); $attributes[] = new Attribute('ORM\DiscriminatorColumn', ['name' => 'discr']); - $attributes[] = new Attribute('ORM\DiscriminatorMap', [array_reduce($mapNames, fn (array $map, string $mapName) => $map + [u($mapName)->camel()->toString() => new Literal(\sprintf('%s::class', $mapName))], [])]); + $attributes[] = new Attribute('ORM\DiscriminatorMap', [array_reduce($mapNames, static fn (array $map, string $mapName) => $map + [u($mapName)->camel()->toString() => new Literal(\sprintf('%s::class', $mapName))], [])]); } else { $attributes[] = new Attribute('ORM\Entity'); } diff --git a/src/ClassMutator/ClassPropertiesAppender.php b/src/ClassMutator/ClassPropertiesAppender.php index 78d04cda..d81d1717 100644 --- a/src/ClassMutator/ClassPropertiesAppender.php +++ b/src/ClassMutator/ClassPropertiesAppender.php @@ -158,7 +158,7 @@ private function getParentClasses(array $graphs, RdfResource $resource, array $p return $this->getParentClasses($graphs, $resource, [$resource]); } - $filterBNodes = fn ($parentClasses) => array_filter($parentClasses, fn ($parentClass) => !$parentClass->isBNode()); + $filterBNodes = static fn ($parentClasses) => array_filter($parentClasses, static fn ($parentClass) => !$parentClass->isBNode()); if (!$subclasses = $resource->all('rdfs:subClassOf', 'resource')) { return $filterBNodes($parentClasses); } diff --git a/src/Model/AddAttributeTrait.php b/src/Model/AddAttributeTrait.php index a30d0d6a..40524679 100644 --- a/src/Model/AddAttributeTrait.php +++ b/src/Model/AddAttributeTrait.php @@ -25,7 +25,7 @@ public function addAttribute(Attribute $attribute): self } } else { $this->attributes = array_map( - fn (Attribute $attr) => $attr->name() === $attribute->name() + static fn (Attribute $attr) => $attr->name() === $attribute->name() ? new Attribute($attr->name(), array_merge( $attr->args(), $attribute->args(), @@ -44,7 +44,7 @@ public function getAttributeWithName(string $name): ?Attribute { return array_values(array_filter( $this->attributes, - fn (Attribute $attr) => $attr->name() === $name + static fn (Attribute $attr) => $attr->name() === $name ))[0] ?? null; } } diff --git a/src/Model/Type/UnionType.php b/src/Model/Type/UnionType.php index 3fd09c86..5fd27a39 100644 --- a/src/Model/Type/UnionType.php +++ b/src/Model/Type/UnionType.php @@ -28,7 +28,7 @@ public function __construct(array $types) public function __toString(): string { - return implode('|', array_map(fn (Type $type) => $type instanceof CompositeType ? '('.$type.')' : $type, $this->types)); + return implode('|', array_map(static fn (Type $type) => $type instanceof CompositeType ? '('.$type.')' : $type, $this->types)); } public function getPhp(): string @@ -39,6 +39,6 @@ public function getPhp(): string $phpTypes[$type->getPhp()] = $type; } - return implode('|', array_map(fn (Type $type) => $type instanceof CompositeType ? '('.$type->getPhp().')' : $type->getPhp(), $phpTypes)); + return implode('|', array_map(static fn (Type $type) => $type instanceof CompositeType ? '('.$type->getPhp().')' : $type->getPhp(), $phpTypes)); } } diff --git a/src/SchemaGeneratorConfiguration.php b/src/SchemaGeneratorConfiguration.php index bb6db5a1..8ca95e16 100644 --- a/src/SchemaGeneratorConfiguration.php +++ b/src/SchemaGeneratorConfiguration.php @@ -46,14 +46,14 @@ public function getConfigTreeBuilder(): TreeBuilder $namespacePrefix = $this->defaultPrefix ?? 'App\\'; /* @see https://yaml.org/type/omap.html */ - $transformOmap = fn (array $nodeConfig) => array_map( - fn ($v, $k) => \is_int($k) ? $v : [$k => $v], + $transformOmap = static fn (array $nodeConfig) => array_map( + static fn ($v, $k) => \is_int($k) ? $v : [$k => $v], array_values($nodeConfig), array_keys($nodeConfig) ); // @phpstan-ignore-next-line node is not null - $attributesNode = fn () => (new NodeBuilder()) + $attributesNode = static fn () => (new NodeBuilder()) ->arrayNode('attributes') ->info('Attributes (merged with generated attributes)') ->variablePrototype()->end() @@ -78,7 +78,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue([self::SCHEMA_ORG_URI => ['format' => 'rdfxml']]) ->beforeNormalization() ->ifArray() - ->then(fn (array $v) => array_map(fn ($rdf) => \is_scalar($rdf) ? ['uri' => $rdf] : $rdf, $v)) + ->then(static fn (array $v) => array_map(static fn ($rdf) => \is_scalar($rdf) ? ['uri' => $rdf] : $rdf, $v)) ->end() ->useAttributeAsKey('uri') ->arrayPrototype() diff --git a/src/TypesGenerator.php b/src/TypesGenerator.php index 23f3ba7a..d42bf7f9 100644 --- a/src/TypesGenerator.php +++ b/src/TypesGenerator.php @@ -313,7 +313,7 @@ private function getParentClasses(array $graphs, RdfResource $resource, array $p return $this->getParentClasses($graphs, $resource, [$resource]); } - $filterBNodes = fn ($parentClasses) => array_filter($parentClasses, fn ($parentClass) => !$parentClass->isBNode()); + $filterBNodes = static fn ($parentClasses) => array_filter($parentClasses, static fn ($parentClass) => !$parentClass->isBNode()); if (!$subclasses = $resource->all('rdfs:subClassOf', 'resource')) { return $filterBNodes($parentClasses); } diff --git a/tests/Command/GenerateCommandTest.php b/tests/Command/GenerateCommandTest.php index f966dbb1..e8d2914d 100644 --- a/tests/Command/GenerateCommandTest.php +++ b/tests/Command/GenerateCommandTest.php @@ -561,4 +561,141 @@ public function testGenerationWithoutConfigFileQuestion(): void $this->assertEquals(0, $commandTester->execute(['output' => sys_get_temp_dir()])); $this->assertMatchesRegularExpression('/The entire vocabulary will be imported/', $commandTester->getDisplay()); } + + 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); + } } 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" ]