Skip to content
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
63 changes: 47 additions & 16 deletions src/OpenApi/Factory/OpenApiFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -492,24 +492,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
(\in_array($method, ['PATCH', 'PUT', 'POST'], true) || true === $operation->canDeserialize())
&& !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class']))
) {
$content = $openapiOperation->getRequestBody()?->getContent();
if (null === $content) {
$operationInputSchemas = [];
foreach ($requestMimeTypes as $operationFormat) {
$operationInputSchema = null;
$operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection);
$this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions());

$operationInputSchemas[$operationFormat] = $operationInputSchema;
}
$content = $this->buildContent($requestMimeTypes, $operationInputSchemas);
$operationInputSchemas = [];
foreach ($requestMimeTypes as $operationFormat) {
$operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection);
$this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions());

$operationInputSchemas[$operationFormat] = $operationInputSchema;
}

$openapiOperation = $openapiOperation->withRequestBody(new RequestBody(
description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName),
content: $content,
required: $openapiOperation->getRequestBody()?->getRequired() ?? true,
));
$openapiOperation = $openapiOperation->withRequestBody($this->buildOpenApiRequestBody($requestMimeTypes, $operationInputSchemas, $openapiOperation, $method, $resourceShortName));
}

// RFC 10008: the HTTP QUERY method carries its criteria in the request body, not the URI
Expand Down Expand Up @@ -563,6 +554,46 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat
return $openapiOperation->withResponse($status, $response);
}

/**
* @param array<string, string> $requestMimeTypes
* @param array<string, Schema> $operationInputSchemas
*/
private function buildOpenApiRequestBody(array $requestMimeTypes, array $operationInputSchemas, Operation $openapiOperation, string $method, string $resourceShortName): RequestBody
{
$content = $openapiOperation->getRequestBody()?->getContent();
if (null === $content) {
$content = $this->buildContent($requestMimeTypes, $operationInputSchemas);
} else {
$generatedContent = $this->buildContent($requestMimeTypes, $operationInputSchemas);
foreach ($generatedContent as $mimeType => $generatedMediaType) {
if (!isset($content[$mimeType])) {
$content[$mimeType] = $generatedMediaType;
continue;
}

$mediaType = $content[$mimeType];
$format = $requestMimeTypes[$mimeType] ?? null;
$operationInputSchema = $format && isset($operationInputSchemas[$format]) ? $operationInputSchemas[$format] : null;

if ($operationInputSchema) {
$schemaArrayObject = new \ArrayObject($operationInputSchema->getArrayCopy(false));
if ($mediaType instanceof MediaType && null === $mediaType->getSchema()) {
$content[$mimeType] = $mediaType->withSchema($schemaArrayObject);
} elseif ((\is_array($mediaType) || $mediaType instanceof \ArrayObject) && !isset($mediaType['schema'])) {
$mediaType['schema'] = $schemaArrayObject;
$content[$mimeType] = $mediaType;
}
}
}
}

return new RequestBody(
description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName),
content: $content,
required: $openapiOperation->getRequestBody()?->getRequired() ?? true,
);
}

/**
* @param array<string, string> $responseMimeTypes
* @param array<string, Schema> $operationSchemas
Expand Down
107 changes: 107 additions & 0 deletions src/OpenApi/Tests/Factory/OpenApiFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,28 @@ public function testInvoke(): void
description: 'Extended description for the new Dummy resource',
),
)),
'postDummyCollectionWithRequestBodyMediaTypeWithoutSchema' => (new Post())->withUriTemplate('/dummiesRequestBodyMediaTypeWithoutSchema')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation(
requestBody: new RequestBody(
content: new \ArrayObject([
'application/ld+json' => new MediaType(
examples: new \ArrayObject([
'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']),
]),
),
]),
),
)),
'postDummyCollectionWithRequestBodyArrayObjectWithoutSchema' => (new Post())->withUriTemplate('/dummiesRequestBodyArrayObjectWithoutSchema')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation(
requestBody: new RequestBody(
content: new \ArrayObject([
'application/ld+json' => new \ArrayObject([
'examples' => new \ArrayObject([
'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']),
]),
]),
]),
),
)),
'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation(
responses: [
'200' => new OpenApiResponse(
Expand Down Expand Up @@ -1065,6 +1087,7 @@ public function testInvoke(): void
],
],
],
'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])),
]),
false
),
Expand Down Expand Up @@ -1107,6 +1130,90 @@ public function testInvoke(): void
),
), $requestBodyPath->getPost());

$requestBodyPath = $paths->getPath('/dummiesRequestBodyMediaTypeWithoutSchema');
$this->assertEquals(new Operation(
'postDummyCollectionWithRequestBodyMediaTypeWithoutSchema',
['Dummy'],
[
'201' => new Response(
'Dummy resource created',
new \ArrayObject([
'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])),
]),
null,
new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')])
),
'400' => new Response(
'Invalid input',
content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]),
links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')])
),
'422' => new Response(
'Unprocessable entity',
content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]),
links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')])
),
],
'Creates a Dummy resource.',
'Creates a Dummy resource.',
null,
[],
new RequestBody(
'The new Dummy resource',
new \ArrayObject([
'application/ld+json' => new MediaType(
schema: new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']),
examples: new \ArrayObject([
'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']),
]),
),
]),
false
),
), $requestBodyPath->getPost());

$requestBodyPath = $paths->getPath('/dummiesRequestBodyArrayObjectWithoutSchema');
$this->assertEquals(new Operation(
'postDummyCollectionWithRequestBodyArrayObjectWithoutSchema',
['Dummy'],
[
'201' => new Response(
'Dummy resource created',
new \ArrayObject([
'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])),
]),
null,
new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')])
),
'400' => new Response(
'Invalid input',
content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]),
links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')])
),
'422' => new Response(
'Unprocessable entity',
content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]),
links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')])
),
],
'Creates a Dummy resource.',
'Creates a Dummy resource.',
null,
[],
new RequestBody(
'The new Dummy resource',
new \ArrayObject([
'application/ld+json' => new \ArrayObject([
'schema' => new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']),
'examples' => new \ArrayObject([
'hello world' => new Model\Example(description: 'Submitting a "hello world" entity', value: ['name' => 'Hello world']),
]),
]),
]),
false
),
), $requestBodyPath->getPost());

$dummyItemPath = $paths->getPath('/dummyitems/{id}');
$this->assertEquals(new Operation(
'putDummyItemWithResponse',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Example;
use ApiPlatform\OpenApi\Model\MediaType;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\RequestBody;

#[ApiResource(
operations: [
new Post(
status: 201,
openapi: new Operation(
requestBody: new RequestBody(
content: new \ArrayObject([
'application/ld+json' => new MediaType(
examples: new \ArrayObject([
'hello world' => new Example(description: 'Submitting a "hello world" entity', value: ['id' => 1, 'name' => 'Hello world']),
]),
),
'application/json' => [
'examples' => [
'foo bar' => ['description' => 'Submitting a "foo bar" entity', 'value' => ['id' => 2, 'name' => 'Foo bar']],
],
],
]),
),
),
),
],
)]
class RequestBodyWithExamplesResource
{
public function __construct(
#[ApiProperty(identifier: true)]
public ?int $id = null,
public ?string $name = null,
) {
}
}
38 changes: 38 additions & 0 deletions tests/Functional/OpenApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\OpenApiPartialUpdateResource;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ParameterOnPropertyWithDefaultKey;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ParentAttribute;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\RequestBodyWithExamplesResource;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbstractDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem;
Expand Down Expand Up @@ -120,6 +121,7 @@ public static function getResources(): array
ParameterOnProperties::class,
ParameterOnPropertiesWithHeaderParameter::class,
ParameterOnPropertyWithDefaultKey::class,
RequestBodyWithExamplesResource::class,
];
}

Expand All @@ -138,6 +140,42 @@ public function testOpenApiReferenceInResponsesUsesDollarRef(): void
$this->assertArrayNotHasKey('ref', $responses['401']);
}

public function testRequestBodyExamplesRetainAutoGeneratedSchema(): void
{
$response = self::createClient()->request('GET', '/docs', [
'headers' => ['Accept' => 'application/vnd.openapi+json'],
]);

$this->assertResponseIsSuccessful();
$json = $response->toArray();

$postContent = $json['paths']['/request_body_with_examples_resources']['post']['requestBody']['content'];

$expectedMimeTypes = [
'application/ld+json' => '#/components/schemas/RequestBodyWithExamplesResource',
'application/hal+json' => '#/components/schemas/RequestBodyWithExamplesResource.jsonhal',
'application/vnd.api+json' => '#/components/schemas/RequestBodyWithExamplesResource',
'application/xml' => '#/components/schemas/RequestBodyWithExamplesResource',
'text/xml' => '#/components/schemas/RequestBodyWithExamplesResource',
'application/json' => '#/components/schemas/RequestBodyWithExamplesResource',
'text/html' => '#/components/schemas/RequestBodyWithExamplesResource',
'application/graphql' => '#/components/schemas/RequestBodyWithExamplesResource',
'multipart/form-data' => '#/components/schemas/RequestBodyWithExamplesResource',
];

foreach ($expectedMimeTypes as $mimeType => $expectedRef) {
$this->assertArrayHasKey($mimeType, $postContent);
$this->assertArrayHasKey('schema', $postContent[$mimeType]);
$this->assertSame($expectedRef, $postContent[$mimeType]['schema']['$ref']);
}

$this->assertArrayHasKey('examples', $postContent['application/ld+json']);
$this->assertArrayHasKey('hello world', $postContent['application/ld+json']['examples']);

$this->assertArrayHasKey('examples', $postContent['application/json']);
$this->assertArrayHasKey('foo bar', $postContent['application/json']['examples']);
}

public function testDeprecatedPutDoesNotLeakIntoNestedResourceSchema(): void
{
$response = self::createClient()->request('GET', '/docs', [
Expand Down
Loading