Skip to content

Commit

Permalink
fix(jsonapi): add missing "included" schema parts (#6277)
Browse files Browse the repository at this point in the history
* fix(jsonapi): add missing "included" schema parts

* fix(test): test correct format

* chore(jsonschema): refactor definition name logic

* remove useless comment

* remove empty line

* add on invalid

---------

Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
  • Loading branch information
GwendolenLynch and soyuka committed Apr 5, 2024
1 parent 93f8b5f commit 678eb4f
Show file tree
Hide file tree
Showing 15 changed files with 444 additions and 164 deletions.
54 changes: 46 additions & 8 deletions src/JsonApi/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
namespace ApiPlatform\JsonApi\JsonSchema;

use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface;
use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface;
use ApiPlatform\JsonSchema\ResourceMetadataTrait;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;

/**
Expand All @@ -28,6 +31,7 @@
*/
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
{
use ResourceMetadataTrait;
private const LINKS_PROPS = [
'type' => 'object',
'properties' => [
Expand Down Expand Up @@ -102,22 +106,26 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
],
];

public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver)
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?DefinitionNameFactoryInterface $definitionNameFactory = null)
{
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
$this->schemaFactory->setSchemaFactory($this);
}
$this->resourceClassResolver = $resourceClassResolver;
$this->resourceMetadataFactory = $resourceMetadataFactory;
}

/**
* {@inheritdoc}
*/
public function buildSchema(string $className, string $format = 'jsonapi', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
{
$schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
if ('jsonapi' !== $format) {
return $schema;
return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
}
// We don't use the serializer context here as JSON:API doesn't leverage serializer groups for related resources.
// That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes
$schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, [], $forceCollection);

if (($key = $schema->getRootDefinitionKey()) || ($key = $schema->getItemsDefinitionKey())) {
$definitions = $schema->getDefinitions();
Expand All @@ -128,7 +136,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin
return $schema;
}

$definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $schema, $serializerContext);
$definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);

if ($schema->getRootDefinitionKey()) {
return $schema;
Expand Down Expand Up @@ -166,17 +174,27 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
}
}

private function buildDefinitionPropertiesSchema(string $key, string $className, Schema $schema, ?array $serializerContext): array
private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
{
$definitions = $schema->getDefinitions();
$properties = $definitions[$key]['properties'] ?? [];

$attributes = [];
$relationships = [];
$relatedDefinitions = [];
foreach ($properties as $propertyName => $property) {
if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) {
[$isOne, $isMany] = $relation;
[$isOne, $hasOperations, $relatedClassName] = $relation;
if (false === $hasOperations) {
continue;
}

$operation = $this->findOperation($relatedClassName, $type, $operation, $serializerContext);
$inputOrOutputClass = $this->findOutputClass($relatedClassName, $type, $operation, $serializerContext);
$serializerContext ??= $this->getSerializerContext($operation, $type);
$definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext);
$ref = Schema::VERSION_OPENAPI === $schema->getVersion() ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName;
$relatedDefinitions[$propertyName] = ['$ref' => $ref];
if ($isOne) {
$relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS;
continue;
Expand All @@ -197,11 +215,25 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
$replacement = self::PROPERTY_PROPS;
$replacement['attributes']['properties'] = $attributes;

$included = [];
if (\count($relationships) > 0) {
$replacement['relationships'] = [
'type' => 'object',
'properties' => $relationships,
];
$included = [
'included' => [
'description' => 'Related resources requested via the "include" query parameter.',
'type' => 'array',
'items' => [
'anyOf' => array_values($relatedDefinitions),
],
'readOnly' => true,
'externalDocs' => [
'url' => 'https://jsonapi.org/format/#fetching-includes',
],
],
];
}

if ($required = $definitions[$key]['required'] ?? null) {
Expand All @@ -223,7 +255,7 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
'properties' => $replacement,
'required' => ['type', 'id'],
],
];
] + $included;
}

private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array
Expand All @@ -232,6 +264,7 @@ private function getRelationship(string $resourceClass, string $property, ?array
$types = $propertyMetadata->getBuiltinTypes() ?? [];
$isRelationship = false;
$isOne = $isMany = false;
$className = $hasOperations = null;

foreach ($types as $type) {
if ($type->isCollection()) {
Expand All @@ -244,8 +277,13 @@ private function getRelationship(string $resourceClass, string $property, ?array
continue;
}
$isRelationship = true;
$resourceMetadata = $this->resourceMetadataFactory->create($className);
$operation = $resourceMetadata->getOperation();
// @see https://github.com/api-platform/core/issues/5501
// @see https://github.com/api-platform/core/pull/5722
$hasOperations ??= $operation->canRead();
}

return $isRelationship ? [$isOne, $isMany] : null;
return $isRelationship ? [$isOne, $hasOperations, $className] : null;
}
}
64 changes: 64 additions & 0 deletions src/JsonSchema/DefinitionNameFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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\JsonSchema;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;

final class DefinitionNameFactory implements DefinitionNameFactoryInterface
{
use ResourceClassInfoTrait;

public function __construct(private ?array $distinctFormats)
{
}

public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string
{
if ($operation) {
$prefix = $operation->getShortName();
}

if (!isset($prefix)) {
$prefix = (new \ReflectionClass($className))->getShortName();
}

if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
$parts = explode('\\', $inputOrOutputClass);
$shortName = end($parts);
$prefix .= '.'.$shortName;
}

if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) {
// JSON is the default, and so isn't included in the definition name
$prefix .= '.'.$format;
}

$definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null;
if ($definitionName) {
$name = sprintf('%s-%s', $prefix, $definitionName);
} else {
$groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
$name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
}

return $this->encodeDefinitionName($name);
}

private function encodeDefinitionName(string $name): string
{
return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
}
}
33 changes: 33 additions & 0 deletions src/JsonSchema/DefinitionNameFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?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\JsonSchema;

use ApiPlatform\Metadata\Operation;

/**
* Factory for creating definition names for resources in a JSON Schema document.
*
* @author Gwendolen Lynch <gwendolen.lynch@gmail.com>
*/
interface DefinitionNameFactoryInterface
{
/**
* Creates a resource definition name.
*
* @param class-string $className
*
* @return string the definition name
*/
public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string;
}
104 changes: 104 additions & 0 deletions src/JsonSchema/ResourceMetadataTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?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\JsonSchema;

use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;

/**
* @internal
*/
trait ResourceMetadataTrait
{
use ResourceClassInfoTrait;

private function findOutputClass(string $className, string $type, Operation $operation, ?array $serializerContext): ?string
{
$inputOrOutput = ['class' => $className];
$inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
$forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false;

return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
}

private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext): Operation
{
if (null === $operation) {
if (null === $this->resourceMetadataFactory) {
return new HttpOperation();
}
$resourceMetadataCollection = $this->resourceMetadataFactory->create($className);

try {
$operation = $resourceMetadataCollection->getOperation();
} catch (OperationNotFoundException $e) {
$operation = new HttpOperation();
}
$forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false;
if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) {
$operation = new HttpOperation();
}

return $this->findOperationForType($resourceMetadataCollection, $type, $operation);
}

// The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
if ($this->resourceMetadataFactory && !$operation->getClass()) {
$resourceMetadataCollection = $this->resourceMetadataFactory->create($className);

if ($operation->getName()) {
return $resourceMetadataCollection->getOperation($operation->getName());
}

return $this->findOperationForType($resourceMetadataCollection, $type, $operation);
}

return $operation;
}

private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
{
// Find the operation and use the first one that matches criterias
foreach ($resourceMetadataCollection as $resourceMetadata) {
foreach ($resourceMetadata->getOperations() ?? [] as $op) {
if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
$operation = $op;
break 2;
}

if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
$operation = $op;
break 2;
}
}
}

return $operation;
}

private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
{
return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
}

private function getShortClassName(string $fullyQualifiedName): string
{
$parts = explode('\\', $fullyQualifiedName);
return end($parts);
}
}
Loading

0 comments on commit 678eb4f

Please sign in to comment.