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'];