Skip to content
Merged
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
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why would you reset the context?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping you'd bring this up 👍

I haven't mapped it out fully yet, but the JSON:API serializer seems to mostly ignore normalization groups (c.f. include query parameter.

So if it is not a jsonapi format, just pass it on as-is, else generate the schema without groups. Hadn't gotten past that so far. I got distracted with enums.

@soyuka soyuka Apr 3, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you mention the json:api specification in a comment (e.g: the JSON:API serializer ignores normalization groups and uses an include parameter)?

side note I've come accross:

$operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection);

Looks like we don't send the context from the openapi factory but I'm not sure why.


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
Comment thread
GwendolenLynch marked this conversation as resolved.
{
/**
* 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;
Comment thread
GwendolenLynch marked this conversation as resolved.
}
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);
Comment thread
GwendolenLynch marked this conversation as resolved.
}

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);
Comment thread
GwendolenLynch marked this conversation as resolved.

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);
Comment thread
GwendolenLynch marked this conversation as resolved.

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