Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

operation openapi property support #430

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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" ]
Loading