Skip to content

Commit ea27046

Browse files
committed
operation openapi attribute support
1 parent 997f6f8 commit ea27046

File tree

3 files changed

+325
-1
lines changed

3 files changed

+325
-1
lines changed

src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php

+80-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
use ApiPlatform\Metadata\Patch;
2424
use ApiPlatform\Metadata\Post;
2525
use ApiPlatform\Metadata\Put;
26+
use ApiPlatform\OpenApi\Model\Operation;
27+
use ApiPlatform\OpenApi\Model\Parameter;
28+
use ApiPlatform\OpenApi\Model\Response;
2629
use ApiPlatform\SchemaGenerator\Model\Attribute;
2730
use ApiPlatform\SchemaGenerator\Model\Class_;
2831
use ApiPlatform\SchemaGenerator\Model\Property;
@@ -39,6 +42,21 @@
3942
*/
4043
final class ApiPlatformCoreAttributeGenerator extends AbstractAttributeGenerator
4144
{
45+
/**
46+
* Hints for not typed array parameters.
47+
*/
48+
private const PRAMETER_TYPE_HINTS = [
49+
Operation::class => [
50+
'responses' => Response::class.'[]',
51+
'parameters' => Parameter::class.'[]',
52+
],
53+
];
54+
55+
/**
56+
* @var array<class-string, array<string, string|null>>
57+
*/
58+
private static array $parameterTypes = [];
59+
4260
public function generateClassAttributes(Class_ $class): array
4361
{
4462
if ($class->hasChild || $class->isEnum()) {
@@ -84,7 +102,21 @@ public function generateClassAttributes(Class_ $class): array
84102
$operationMetadataClass = $methodConfig['class'];
85103
unset($methodConfig['class']);
86104
}
87-
105+
if (\is_array($methodConfig['openapi'] ?? null)) {
106+
$methodConfig['openapi'] = Literal::new(
107+
'Operation',
108+
self::extractParameters(Operation::class, $methodConfig['openapi'])
109+
);
110+
$class->addUse(new Use_(Operation::class));
111+
array_walk_recursive(
112+
self::$parameterTypes,
113+
function (?string $type) use ($class) {
114+
if (null !== $type) {
115+
$class->addUse(new Use_(str_replace('[]', '', $type)));
116+
}
117+
}
118+
);
119+
}
88120
$arguments['operations'][] = new Literal(sprintf('new %s(...?:)',
89121
$operationMetadataClass,
90122
), [$methodConfig ?? []]);
@@ -95,6 +127,53 @@ public function generateClassAttributes(Class_ $class): array
95127
return [new Attribute('ApiResource', $arguments)];
96128
}
97129

130+
/**
131+
* @param class-string $type
132+
* @param mixed[] $values
133+
*
134+
* @return mixed[]
135+
*/
136+
private static function extractParameters(string $type, array $values): array
137+
{
138+
$types = self::$parameterTypes[$type] ??=
139+
(static::PRAMETER_TYPE_HINTS[$type] ?? []) + array_reduce(
140+
(new \ReflectionClass($type))->getConstructor()?->getParameters() ?? [],
141+
static fn (array $types, \ReflectionParameter $refl) => $types + [
142+
$refl->getName() => $refl->getType() instanceof \ReflectionNamedType
143+
&& !$refl->getType()->isBuiltin()
144+
? $refl->getType()->getName()
145+
: null,
146+
],
147+
[]
148+
);
149+
150+
$parameters = array_intersect_key($values, $types);
151+
foreach ($parameters as $name => $parameter) {
152+
$type = $types[$name];
153+
if (null !== $type && \is_array($parameter)) {
154+
$isArrayType = str_ends_with($type, '[]');
155+
$type = $isArrayType ? substr($type, 0, -2) : $type;
156+
$shortName = (new \ReflectionClass($type))->getShortName();
157+
$parameters[$name] = $isArrayType
158+
? array_map(
159+
static fn (array $values) => Literal::new(
160+
$shortName,
161+
self::extractParameters($type, $values)
162+
),
163+
$parameter
164+
)
165+
: Literal::new(
166+
$shortName,
167+
\ArrayObject::class === $type
168+
? [$parameter]
169+
: self::extractParameters($type, $parameter)
170+
);
171+
}
172+
}
173+
174+
return $parameters;
175+
}
176+
98177
/**
99178
* Verifies that the operations' config is valid.
100179
*

tests/Command/GenerateCommandTest.php

+139
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,145 @@ class Page extends Object_
539539
self::assertFalse($this->fs->exists("$outputDir/App/Entity/Travel.php"));
540540
}
541541

542+
public function testOpenapiOperationProperty(): void
543+
{
544+
$outputDir = __DIR__.'/../../build/openapi-operation-property';
545+
$config = __DIR__.'/../config/openapi-operation-property.yaml';
546+
547+
$this->fs->mkdir($outputDir);
548+
549+
$commandTester = new CommandTester(new GenerateCommand());
550+
$this->assertEquals(0, $commandTester->execute(['output' => $outputDir, 'config' => $config]));
551+
$source = file_get_contents("$outputDir/App/Entity/Saml.php");
552+
553+
$this->assertStringContainsString(<<<'PHP'
554+
use ApiPlatform\OpenApi\Model\Operation;
555+
use ApiPlatform\OpenApi\Model\Parameter;
556+
use ApiPlatform\OpenApi\Model\RequestBody;
557+
use ApiPlatform\OpenApi\Model\Response;
558+
PHP
559+
, $source);
560+
561+
$this->assertStringContainsString(<<<'PHP'
562+
#[ApiResource(
563+
shortName: 'Saml',
564+
types: ['https://schema.org/Thing'],
565+
operations: [
566+
new Get(
567+
name: 'login',
568+
uriTemplate: '/saml/{id}/login',
569+
controller: 'App\Controller\SamlController::login',
570+
openapi: new Operation(
571+
tags: ['Auth'],
572+
summary: 'SAML authentication.',
573+
description: 'SAML authentication.',
574+
responses: [
575+
302 => new Response(
576+
description: 'Initialization successful.',
577+
headers: new \ArrayObject([
578+
'Location' => [
579+
'required' => true,
580+
'description' => 'SAML login page redirection.',
581+
'schema' => ['type' => 'string', 'format' => 'url'],
582+
],
583+
]),
584+
),
585+
403 => new Response(description: 'SAML disabled.'),
586+
404 => new Response(description: 'SAML not found.'),
587+
],
588+
),
589+
),
590+
new Post(
591+
name: 'acs',
592+
uriTemplate: '/saml/{id}/acs',
593+
controller: 'App\Controller\SamlController::acs',
594+
inputFormats: ['urlencoded' => ['application/x-www-form-urlencoded']],
595+
openapi: new Operation(
596+
tags: ['Auth'],
597+
summary: 'SAML ACS.',
598+
description: 'SAML ACS.',
599+
responses: [
600+
302 => new Response(
601+
description: 'Authentication successful.',
602+
headers: new \ArrayObject([
603+
'Location' => [
604+
'required' => true,
605+
'description' => 'Redirection page.',
606+
'schema' => ['type' => 'string', 'format' => 'url'],
607+
],
608+
]),
609+
),
610+
401 => new Response(description: 'Authentication failed.'),
611+
403 => new Response(description: 'SAML disabled.'),
612+
404 => new Response(description: 'SAML not found.'),
613+
],
614+
requestBody: new RequestBody(
615+
required: true,
616+
content: new \ArrayObject([
617+
'application/x-www-form-urlencoded' => [
618+
'schema' => [
619+
'type' => 'object',
620+
'properties' => ['SAMLResponse' => ['type' => 'string', 'description' => 'SAML login response.']],
621+
],
622+
'required' => ['SAMLResponse'],
623+
],
624+
]),
625+
),
626+
),
627+
),
628+
new Get(
629+
name: 'logout',
630+
uriTemplate: '/saml/{id}/logout',
631+
controller: 'App\Controller\SamlController::logout',
632+
openapi: new Operation(
633+
tags: ['Auth'],
634+
summary: 'SAML logout.',
635+
description: 'SAML logout.',
636+
parameters: [
637+
new Parameter(
638+
name: 'SAMLRequest',
639+
in: 'query',
640+
schema: ['type' => 'string'],
641+
required: true,
642+
description: 'SAML logout request.',
643+
),
644+
new Parameter(
645+
name: 'RelayState',
646+
in: 'query',
647+
schema: ['type' => 'string'],
648+
required: false,
649+
description: 'SAML logout response redirect URL.',
650+
),
651+
new Parameter(
652+
name: 'Signature',
653+
in: 'query',
654+
schema: ['type' => 'string'],
655+
required: false,
656+
description: 'SAML signature.',
657+
),
658+
],
659+
responses: [
660+
302 => new Response(
661+
description: 'Logout successful.',
662+
headers: new \ArrayObject([
663+
'Location' => [
664+
'required' => true,
665+
'description' => 'SAML logout response redirect URL.',
666+
'schema' => ['type' => 'string', 'format' => 'url'],
667+
],
668+
]),
669+
),
670+
403 => new Response(description: 'Logout failed.'),
671+
404 => new Response(description: 'SAML not found.'),
672+
],
673+
),
674+
),
675+
],
676+
)]
677+
PHP
678+
, $source);
679+
}
680+
542681
public function testGenerationWithoutConfigFileQuestion(): void
543682
{
544683
// No config file is given.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
types:
2+
Saml:
3+
operations:
4+
login:
5+
class: Get
6+
name: "login"
7+
uriTemplate: "/saml/{id}/login"
8+
controller: "App\\Controller\\SamlController::login"
9+
openapi:
10+
tags: ["Auth"]
11+
summary: "SAML authentication."
12+
description: "SAML authentication."
13+
responses:
14+
302:
15+
description: "Initialization successful."
16+
headers:
17+
Location:
18+
required: true
19+
description: "SAML login page redirection."
20+
schema:
21+
type: "string"
22+
format: "url"
23+
403: { description: "SAML disabled." }
24+
404: { description: "SAML not found." }
25+
acs:
26+
class: Post
27+
name: "acs"
28+
uriTemplate: "/saml/{id}/acs"
29+
controller: "App\\Controller\\SamlController::acs"
30+
inputFormats:
31+
urlencoded: ['application/x-www-form-urlencoded']
32+
openapi:
33+
tags: ["Auth"]
34+
summary: "SAML ACS."
35+
description: "SAML ACS."
36+
responses:
37+
302:
38+
description: "Authentication successful."
39+
headers:
40+
Location:
41+
required: true
42+
description: "Redirection page."
43+
schema:
44+
type: "string"
45+
format: "url"
46+
401: { description: "Authentication failed."}
47+
403: { description: "SAML disabled." }
48+
404: { description: "SAML not found." }
49+
requestBody:
50+
required: true
51+
content:
52+
"application/x-www-form-urlencoded":
53+
schema:
54+
type: object
55+
properties:
56+
SAMLResponse:
57+
type: string
58+
description: "SAML login response."
59+
required: ["SAMLResponse"]
60+
logout:
61+
class: Get
62+
name: "logout"
63+
uriTemplate: "/saml/{id}/logout"
64+
controller: "App\\Controller\\SamlController::logout"
65+
openapi:
66+
tags: ["Auth"]
67+
summary: "SAML logout."
68+
description: "SAML logout."
69+
parameters:
70+
- name: "SAMLRequest"
71+
in: "query"
72+
schema: { type: "string" }
73+
required: true
74+
description: "SAML logout request."
75+
- name: "RelayState"
76+
in: "query"
77+
schema: { type: "string" }
78+
required: false
79+
description: "SAML logout response redirect URL."
80+
- name: "Signature"
81+
in: "query"
82+
schema: { type: "string" }
83+
required: false
84+
description: "SAML signature."
85+
responses:
86+
302:
87+
description: "Logout successful."
88+
headers:
89+
Location:
90+
required: true
91+
description: "SAML logout response redirect URL."
92+
schema:
93+
type: "string"
94+
format: "url"
95+
96+
403: { description: "Logout failed." }
97+
404: { description: "SAML not found." }
98+
attributes:
99+
ApiResource:
100+
shortName: "Saml"
101+
properties:
102+
name:
103+
nullable: false
104+
attributes:
105+
ApiProperty:
106+
iris: [ "https://schema.org/name" ]

0 commit comments

Comments
 (0)