diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php new file mode 100644 index 00000000000..f25c261b9c7 --- /dev/null +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -0,0 +1,266 @@ + + * + * 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\JsonApi\JsonSchema; + +use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; +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\ResourceClassResolverInterface; + +/** + * Decorator factory which adds JSON:API properties to the JSON Schema document. + * + * @author Gwendolen Lynch + */ +final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface +{ + private const LINKS_PROPS = [ + 'type' => 'object', + 'properties' => [ + 'self' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + 'first' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + 'prev' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + 'next' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + 'last' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + ], + 'example' => [ + 'self' => 'string', + 'first' => 'string', + 'prev' => 'string', + 'next' => 'string', + 'last' => 'string', + ], + ]; + private const META_PROPS = [ + 'type' => 'object', + 'properties' => [ + 'totalItems' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'itemsPerPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'currentPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + ], + ]; + private const RELATION_PROPS = [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + ], + 'id' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + ], + ]; + private const PROPERTY_PROPS = [ + 'id' => [ + 'type' => 'string', + ], + 'type' => [ + 'type' => 'string', + ], + 'attributes' => [ + 'type' => 'object', + 'properties' => [], + ], + ]; + + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver) + { + if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { + $this->schemaFactory->setSchemaFactory($this); + } + } + + /** + * {@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; + } + + if ('input' === $type) { + return $schema; + } + + if ($key = $schema->getRootDefinitionKey()) { + $definitions = $schema->getDefinitions(); + $properties = $definitions[$key]['properties'] ?? []; + + // Prevent reapplying + if (isset($properties['id'], $properties['type']) || isset($properties['data'])) { + return $schema; + } + + $definitions[$key]['properties'] = [ + 'data' => [ + 'type' => 'object', + 'properties' => $this->buildDefinitionPropertiesSchema($key, $className, $schema, $serializerContext), + 'required' => ['type', 'id'], + ], + ]; + + return $schema; + } + + if ($key = $schema->getItemsDefinitionKey()) { + $definitions = $schema->getDefinitions(); + $properties = $definitions[$key]['properties'] ?? []; + + // Prevent reapplying + if (isset($properties['id'], $properties['type']) || isset($properties['data'])) { + return $schema; + } + + $definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $schema, $serializerContext); + $definitions[$key]['required'] = ['type', 'id']; + } + + if (($schema['type'] ?? '') === 'array') { + // data + $items = $schema['items']; + unset($schema['items']); + + $schema['type'] = 'object'; + $schema['properties'] = [ + 'links' => self::LINKS_PROPS, + 'meta' => self::META_PROPS, + 'data' => [ + 'type' => 'array', + 'items' => $items, + ], + ]; + $schema['required'] = [ + 'data', + ]; + + return $schema; + } + + return $schema; + } + + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void + { + if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { + $this->schemaFactory->setSchemaFactory($schemaFactory); + } + } + + private function buildDefinitionPropertiesSchema(string $key, string $className, Schema $schema, ?array $serializerContext): array + { + $definitions = $schema->getDefinitions(); + $properties = $definitions[$key]['properties'] ?? []; + + $attributes = []; + $relationships = []; + foreach ($properties as $propertyName => $property) { + if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) { + [$isOne, $isMany] = $relation; + + if ($isOne) { + $relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS; + continue; + } + $relationships[$propertyName]['properties']['data'] = [ + 'type' => 'array', + 'items' => self::RELATION_PROPS, + ]; + continue; + } + if ('id' === $propertyName) { + $attributes['_id'] = $property; + continue; + } + $attributes[$propertyName] = $property; + } + + $replacement = self::PROPERTY_PROPS; + $replacement['attributes']['properties'] = $attributes; + + if (\count($relationships) > 0) { + $replacement['relationships'] = [ + 'type' => 'object', + 'properties' => $relationships, + ]; + } + + if ($required = $definitions[$key]['required'] ?? null) { + foreach ($required as $require) { + if (isset($replacement['attributes']['properties'][$require])) { + $replacement['attributes']['required'][] = $require; + continue; + } + if (isset($relationships[$require])) { + $replacement['relationships']['required'][] = $require; + } + } + unset($definitions[$key]['required']); + } + + return $replacement; + } + + private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array + { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []); + $types = $propertyMetadata->getBuiltinTypes() ?? []; + $isRelationship = false; + $isOne = $isMany = false; + + foreach ($types as $type) { + if ($type->isCollection()) { + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } else { + $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } + if (!isset($className) || (!$isOne && !$isMany)) { + continue; + } + $isRelationship = true; + } + + return $isRelationship ? [$isOne, $isMany] : null; + } +} diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Symfony/Bundle/Resources/config/jsonapi.xml index 671332e8784..bd9904f4de0 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Symfony/Bundle/Resources/config/jsonapi.xml @@ -5,6 +5,12 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + + + + + jsonapi diff --git a/tests/JsonApi/JsonSchema/SchemaFactoryTest.php b/tests/JsonApi/JsonSchema/SchemaFactoryTest.php new file mode 100644 index 00000000000..44385b7d555 --- /dev/null +++ b/tests/JsonApi/JsonSchema/SchemaFactoryTest.php @@ -0,0 +1,182 @@ + + * + * 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\JsonApi\JsonSchema; + +use ApiPlatform\Hal\JsonSchema\SchemaFactory as HalSchemaFactory; +use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; +use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +class SchemaFactoryTest extends TestCase +{ + use ProphecyTrait; + + private SchemaFactory $schemaFactory; + + protected function setUp(): void + { + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withName('get'), + ])), + ])); + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $baseSchemaFactory = new BaseSchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + distinctFormats: ['jsonhal' => true, 'jsonapi' => true, 'jsonld' => true], + ); + + $halSchemaFactory = new HalSchemaFactory($baseSchemaFactory); + $hydraSchemaFactory = new HydraSchemaFactory($halSchemaFactory); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + + $this->schemaFactory = new SchemaFactory( + schemaFactory: $hydraSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + ); + } + + public function testBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + + $this->assertTrue($resultSchema->isDefined()); + $this->assertSame('Dummy.jsonapi', $resultSchema->getRootDefinitionKey()); + } + + public function testCustomFormatBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'json'); + + $this->assertTrue($resultSchema->isDefined()); + $this->assertSame('Dummy', $resultSchema->getRootDefinitionKey()); + } + + public function testHasRootDefinitionKeyBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + // @noRector + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + // @noRector + $this->assertTrue(isset($definitions[$rootDefinitionKey]['properties'])); + $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; + $this->assertArrayHasKey('data', $properties); + $this->assertEquals( + [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + ], + 'type' => [ + 'type' => 'string', + ], + 'attributes' => [ + 'type' => 'object', + 'properties' => [ + ], + ], + ], + 'required' => [ + 'type', + 'id', + ], + ], + $properties['data'] + ); + } + + public function testSchemaTypeBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new GetCollection()); + $definitionName = 'Dummy.jsonapi'; + + $this->assertNull($resultSchema->getRootDefinitionKey()); + // @noRector + $this->assertTrue(isset($resultSchema['properties'])); + $this->assertArrayHasKey('links', $resultSchema['properties']); + $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); + + $this->assertArrayHasKey('meta', $resultSchema['properties']); + $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); + + $this->assertArrayHasKey('data', $resultSchema['properties']); + $this->assertArrayHasKey('items', $resultSchema['properties']['data']); + $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); + + $properties = $resultSchema['definitions'][$definitionName]['properties']; + $this->assertArrayHasKey('id', $properties); + $this->assertArrayHasKey('type', $properties); + $this->assertArrayHasKey('attributes', $properties); + + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + + $this->assertNull($resultSchema->getRootDefinitionKey()); + // @noRector + $this->assertTrue(isset($resultSchema['properties'])); + $this->assertArrayHasKey('links', $resultSchema['properties']); + $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); + $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); + + $this->assertArrayHasKey('meta', $resultSchema['properties']); + $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); + + $this->assertArrayHasKey('data', $resultSchema['properties']); + $this->assertArrayHasKey('items', $resultSchema['properties']['data']); + $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); + + $properties = $resultSchema['definitions'][$definitionName]['properties']; + $this->assertArrayHasKey('id', $properties); + $this->assertArrayHasKey('type', $properties); + $this->assertArrayHasKey('attributes', $properties); + } +} diff --git a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php index 88c2c7d5cb2..02b97e93406 100644 --- a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php +++ b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php @@ -35,7 +35,7 @@ class ApiTestCaseTest extends ApiTestCase public static function providerFormats(): iterable { - // yield 'jsonapi' => ['jsonapi', 'application/vnd.api+json']; + yield 'jsonapi' => ['jsonapi', 'application/vnd.api+json']; yield 'jsonhal' => ['jsonhal', 'application/hal+json']; yield 'jsonld' => ['jsonld', 'application/ld+json']; }