diff --git a/src/Core/Bridge/Symfony/Bundle/Resources/config/hal.xml b/src/Core/Bridge/Symfony/Bundle/Resources/config/hal.xml index 5095e6b41ab..c7a5fbec648 100644 --- a/src/Core/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Core/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -54,6 +54,12 @@ + + + + + + diff --git a/src/Core/Hal/JsonSchema/SchemaFactory.php b/src/Core/Hal/JsonSchema/SchemaFactory.php new file mode 100644 index 00000000000..4b74f503eeb --- /dev/null +++ b/src/Core/Hal/JsonSchema/SchemaFactory.php @@ -0,0 +1,137 @@ + + * + * 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\Core\Hal\JsonSchema; + +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface; + +/** + * Decorator factory which adds HAL properties to the JSON Schema document. + * + * @experimental + * + * @author Kévin Dunglas + * @author Jachim Coudenys + */ +final class SchemaFactory implements SchemaFactoryInterface +{ + private const HREF_PROP = [ + 'href' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + ]; + private const BASE_PROPS = [ + '_links' => [ + 'type' => 'object', + 'properties' => [ + 'self' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + ], + ], + ]; + + private $schemaFactory; + + public function __construct(SchemaFactoryInterface $schemaFactory) + { + $this->schemaFactory = $schemaFactory; + + $this->addDistinctFormat('jsonhal'); + } + + /** + * {@inheritdoc} + */ + public function buildSchema(string $className, string $format = 'jsonhal', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema + { + $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operationType, $operationName, $schema, $serializerContext, $forceCollection); + if ('jsonhal' !== $format) { + return $schema; + } + + $definitions = $schema->getDefinitions(); + if ($key = $schema->getRootDefinitionKey()) { + $definitions[$key]['properties'] = self::BASE_PROPS + ($definitions[$key]['properties'] ?? []); + + return $schema; + } + if ($key = $schema->getItemsDefinitionKey()) { + $definitions[$key]['properties'] = self::BASE_PROPS + ($definitions[$key]['properties'] ?? []); + } + + if (($schema['type'] ?? '') === 'array') { + $items = $schema['items']; + unset($schema['items']); + + $schema['type'] = 'object'; + $schema['properties'] = [ + '_embedded' => [ + 'type' => 'array', + 'items' => $items, + ], + 'totalItems' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'itemsPerPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + '_links' => [ + 'type' => 'object', + 'properties' => [ + 'self' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'first' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'last' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'next' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'previous' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + ], + ], + ]; + $schema['required'] = [ + '_links', + '_embedded', + ]; + + return $schema; + } + + return $schema; + } + + public function addDistinctFormat(string $format): void + { + if (method_exists($this->schemaFactory, 'addDistinctFormat')) { + $this->schemaFactory->addDistinctFormat($format); + } + } +} diff --git a/src/Core/Hydra/JsonSchema/SchemaFactory.php b/src/Core/Hydra/JsonSchema/SchemaFactory.php index 4c5d3a1ba68..2a7445dde47 100644 --- a/src/Core/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Core/Hydra/JsonSchema/SchemaFactory.php @@ -64,9 +64,7 @@ public function __construct(SchemaFactoryInterface $schemaFactory) { $this->schemaFactory = $schemaFactory; - if ($schemaFactory instanceof BaseSchemaFactory) { - $schemaFactory->addDistinctFormat('jsonld'); - } + $this->addDistinctFormat('jsonld'); } /** @@ -173,4 +171,11 @@ public function buildSchema(string $className, string $format = 'jsonld', string return $schema; } + + public function addDistinctFormat(string $format): void + { + if ($this->schemaFactory instanceof BaseSchemaFactory) { + $this->schemaFactory->addDistinctFormat($format); + } + } } diff --git a/tests/Core/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Core/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 51248ac0566..33a7cd946fa 100644 --- a/tests/Core/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Core/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1266,6 +1266,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar { $hasSwagger = null === $configuration || true === $configuration['api_platform']['enable_swagger'] ?? false; $hasHydra = null === $configuration || isset($configuration['api_platform']['formats']['jsonld']); + $hasHal = null === $configuration || isset($configuration['api_platform']['formats']['jsonhal']); $containerBuilderProphecy = $this->getPartialContainerBuilderProphecy($configuration); @@ -1607,6 +1608,10 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar $definitions[] = 'api_platform.jsonld.normalizer.object'; } + if ($hasHal) { + $definitions[] = 'api_platform.hal.json_schema.schema_factory'; + } + // Ignore inlined services $containerBuilderProphecy->setDefinition(Argument::that(static function (string $arg) { return 0 === strpos($arg, '.'); diff --git a/tests/Core/Hal/JsonSchema/SchemaFactoryTest.php b/tests/Core/Hal/JsonSchema/SchemaFactoryTest.php new file mode 100644 index 00000000000..d9b44eb7f7f --- /dev/null +++ b/tests/Core/Hal/JsonSchema/SchemaFactoryTest.php @@ -0,0 +1,131 @@ + + * + * 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\Core\Tests\Hal\JsonSchema; + +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\Hal\JsonSchema\SchemaFactory; +use ApiPlatform\Core\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Core\JsonSchema\TypeFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; + +/** + * @group legacy + */ +class SchemaFactoryTest extends TestCase +{ + use ProphecyTrait; + + private $schemaFactory; + + protected function setUp(): void + { + $typeFactory = $this->prophesize(TypeFactoryInterface::class); + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(Dummy::class)); + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true])->willReturn(new PropertyNameCollection()); + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $baseSchemaFactory = new BaseSchemaFactory( + $typeFactory->reveal(), + $resourceMetadataFactory->reveal(), + $propertyNameCollectionFactory->reveal(), + $propertyMetadataFactory->reveal() + ); + + $hydraSchemaFactory = new HydraSchemaFactory($baseSchemaFactory); + + $this->schemaFactory = new SchemaFactory($hydraSchemaFactory); + } + + public function testBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + + $this->assertTrue($resultSchema->isDefined()); + $this->assertEquals(str_replace('\\', '.', Dummy::class).'.jsonhal', $resultSchema->getRootDefinitionKey()); + } + + public function testCustomFormatBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'json'); + + $this->assertTrue($resultSchema->isDefined()); + $this->assertEquals(str_replace('\\', '.', Dummy::class), $resultSchema->getRootDefinitionKey()); + } + + public function testHasRootDefinitionKeyBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $this->assertArrayHasKey($rootDefinitionKey, $definitions); + $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); + $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; + $this->assertArrayHasKey('_links', $properties); + $this->assertSame( + [ + 'type' => 'object', + 'properties' => [ + 'self' => [ + 'type' => 'object', + 'properties' => [ + 'href' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + ], + ], + ], + ], + $properties['_links'] + ); + } + + public function testSchemaTypeBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonhal', Schema::TYPE_OUTPUT, OperationType::COLLECTION); + $definitionName = str_replace('\\', '.', Dummy::class).'.jsonhal'; + + $this->assertNull($resultSchema->getRootDefinitionKey()); + $this->assertArrayHasKey('properties', $resultSchema); + $this->assertArrayHasKey('_embedded', $resultSchema['properties']); + $this->assertArrayHasKey('totalItems', $resultSchema['properties']); + $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']); + $this->assertArrayHasKey('_links', $resultSchema['properties']); + $properties = $resultSchema['definitions'][$definitionName]['properties']; + $this->assertArrayHasKey('_links', $properties); + + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonhal', Schema::TYPE_OUTPUT, null, null, null, null, true); + + $this->assertNull($resultSchema->getRootDefinitionKey()); + $this->assertArrayHasKey('properties', $resultSchema); + $this->assertArrayHasKey('_embedded', $resultSchema['properties']); + $this->assertArrayHasKey('totalItems', $resultSchema['properties']); + $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']); + $this->assertArrayHasKey('_links', $resultSchema['properties']); + $properties = $resultSchema['definitions'][$definitionName]['properties']; + $this->assertArrayHasKey('_links', $properties); + } +} diff --git a/tests/Core/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Core/Hydra/JsonSchema/SchemaFactoryTest.php index 9a49f041016..d1626aed920 100644 --- a/tests/Core/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Core/Hydra/JsonSchema/SchemaFactoryTest.php @@ -78,7 +78,6 @@ public function testHasRootDefinitionKeyBuildSchema(): void $definitions = $resultSchema->getDefinitions(); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $this->assertEquals(str_replace('\\', '.', Dummy::class).'.jsonld', $rootDefinitionKey); $this->assertArrayHasKey($rootDefinitionKey, $definitions); $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties'];