Skip to content

Commit

Permalink
operation openapi attribute support
Browse files Browse the repository at this point in the history
  • Loading branch information
PawelSuwinski committed Sep 20, 2024
1 parent 997f6f8 commit ea27046
Show file tree
Hide file tree
Showing 3 changed files with 325 additions and 1 deletion.
81 changes: 80 additions & 1 deletion src/AttributeGenerator/ApiPlatformCoreAttributeGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<class-string, array<string, string|null>>
*/
private static array $parameterTypes = [];

public function generateClassAttributes(Class_ $class): array
{
if ($class->hasChild || $class->isEnum()) {
Expand Down Expand Up @@ -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 ?? []]);
Expand All @@ -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.
*
Expand Down
139 changes: 139 additions & 0 deletions tests/Command/GenerateCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 106 additions & 0 deletions tests/config/openapi-operation-property.yaml
Original file line number Diff line number Diff line change
@@ -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" ]

0 comments on commit ea27046

Please sign in to comment.