From 1137354063aea37fffa189abae70c0a00f50ff0c Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 6 Oct 2026 22:15:18 +0200 Subject: [PATCH 01/13] feat(jsonschema): adapter over Symfony JsonSchema Work in progress for symfony/symfony#64279. Golden tests compare buildSchema() with generate() on the spiked component (json-schema-component @ 1e167b63d5f), which has to be autoloaded locally for now. --- .../Generator/ApiPlatformDefinitionPolicy.php | 256 ++++++++++ .../ApiPlatformPropertyInfoExtractor.php | 78 +++ .../Generator/ApiPropertySchemaEnricher.php | 98 ++++ .../Generator/ApiPropertySchemaProvider.php | 41 ++ .../BuiltinTypeDefinitionProcessor.php | 78 +++ .../Generator/ConfigurationFactory.php | 144 ++++++ .../Generator/GeneratorSchemaFactory.php | 89 ++++ .../PartialUpdateDefinitionProcessor.php | 34 ++ .../Generator/PropertyMetadataAccessor.php | 73 +++ .../ResourceIriClassSchemaResolver.php | 65 +++ .../Generator/SchemaGenerationRequest.php | 37 ++ .../AttributeSelectedAuthor.php | 43 ++ .../AttributeSelectedBook.php | 58 +++ .../JsonSchemaGolden/BuiltinTypeQuirks.php | 35 ++ .../JsonSchemaGolden/GoldenAuthor.php | 25 + .../JsonSchemaGolden/GoldenBook.php | 61 +++ .../JsonSchemaGolden/GoldenGroupedItem.php | 52 ++ .../JsonSchemaGolden/GoldenInputResource.php | 78 +++ .../GoldenMultiResourceEntity.php | 64 +++ .../GoldenMultiResourceOwner.php | 54 +++ .../JsonSchemaGolden/GoldenNestedResource.php | 52 ++ .../GoldenNonStandardPutChild.php | 56 +++ .../JsonSchemaGolden/GoldenOutputDto.php | 21 + .../GoldenOutputDtoResource.php | 40 ++ .../GoldenPartialChildrenOwner.php | 62 +++ .../JsonSchemaGolden/GoldenPatchOnlyChild.php | 55 +++ .../JsonSchemaGolden/GoldenPlainObject.php | 26 + .../JsonSchemaGolden/GoldenQueryCriteria.php | 24 + .../JsonSchemaGolden/GoldenQueryResource.php | 43 ++ .../JsonSchemaGolden/GoldenRelated.php | 45 ++ .../GoldenRelationResource.php | 60 +++ .../GoldenStrictInputResource.php | 40 ++ .../GoldenValidatedResource.php | 49 ++ .../JsonSchema/GoldenSchemaTest.php | 459 ++++++++++++++++++ .../JsonSchema/golden/dialect_openapi.json | 36 ++ .../JsonSchema/golden/dialect_swagger.json | 35 ++ .../golden/groups_explicit_context_split.json | 38 ++ .../JsonSchema/golden/groups_normal.json | 29 ++ .../input_attribute_selected_relation.json | 36 ++ .../golden/input_nested_partial_children.json | 58 +++ .../golden/input_no_extra_attributes.json | 24 + .../JsonSchema/golden/input_patch_merge.json | 31 ++ .../JsonSchema/golden/input_post_groups.json | 34 ++ .../golden/input_put_nonstandard.json | 31 ++ .../JsonSchema/golden/input_query.json | 26 + .../golden/input_validation_groups.json | 25 + .../JsonSchema/golden/jsonapi_input_post.json | 34 ++ .../golden/jsonapi_output_item.json | 88 ++++ .../golden/jsonapi_relation_iri_vs_embed.json | 242 +++++++++ .../golden/jsonhal_output_item.json | 64 +++ .../golden/jsonhal_relation_iri_vs_embed.json | 78 +++ .../golden/jsonld_gen_id_false.json | 132 +++++ .../JsonSchema/golden/jsonld_input_post.json | 34 ++ .../golden/jsonld_output_collection.json | 206 ++++++++ .../JsonSchema/golden/jsonld_output_item.json | 84 ++++ .../golden/jsonld_relation_iri_vs_embed.json | 106 ++++ .../JsonSchema/golden/mcp_tool_output.json | 12 + .../golden/multi_resource_nested_input.json | 32 ++ .../golden/multi_resource_nested_output.json | 44 ++ .../JsonSchema/golden/non_resource_class.json | 27 ++ .../output_attribute_selected_relation.json | 36 ++ .../golden/output_builtin_type_quirks.json | 41 ++ .../JsonSchema/golden/output_collection.json | 35 ++ .../golden/output_definition_name.json | 37 ++ .../JsonSchema/golden/output_dto.json | 21 + .../golden/output_gen_id_false.json | 38 ++ .../golden/output_ignored_attributes.json | 46 ++ .../JsonSchema/golden/output_item.json | 37 ++ .../golden/output_nested_attributes.json | 36 ++ .../golden/output_relation_iri_vs_embed.json | 52 ++ 70 files changed, 4460 insertions(+) create mode 100644 src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php create mode 100644 src/JsonSchema/Generator/ApiPlatformPropertyInfoExtractor.php create mode 100644 src/JsonSchema/Generator/ApiPropertySchemaEnricher.php create mode 100644 src/JsonSchema/Generator/ApiPropertySchemaProvider.php create mode 100644 src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php create mode 100644 src/JsonSchema/Generator/ConfigurationFactory.php create mode 100644 src/JsonSchema/Generator/GeneratorSchemaFactory.php create mode 100644 src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php create mode 100644 src/JsonSchema/Generator/PropertyMetadataAccessor.php create mode 100644 src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php create mode 100644 src/JsonSchema/Generator/SchemaGenerationRequest.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedAuthor.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedBook.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/BuiltinTypeQuirks.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenAuthor.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenBook.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenGroupedItem.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenInputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceEntity.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceOwner.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNestedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNonStandardPutChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPartialChildrenOwner.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPatchOnlyChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPlainObject.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryCriteria.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelated.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelationResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenStrictInputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenValidatedResource.php create mode 100644 tests/Functional/JsonSchema/GoldenSchemaTest.php create mode 100644 tests/Functional/JsonSchema/golden/dialect_openapi.json create mode 100644 tests/Functional/JsonSchema/golden/dialect_swagger.json create mode 100644 tests/Functional/JsonSchema/golden/groups_explicit_context_split.json create mode 100644 tests/Functional/JsonSchema/golden/groups_normal.json create mode 100644 tests/Functional/JsonSchema/golden/input_attribute_selected_relation.json create mode 100644 tests/Functional/JsonSchema/golden/input_nested_partial_children.json create mode 100644 tests/Functional/JsonSchema/golden/input_no_extra_attributes.json create mode 100644 tests/Functional/JsonSchema/golden/input_patch_merge.json create mode 100644 tests/Functional/JsonSchema/golden/input_post_groups.json create mode 100644 tests/Functional/JsonSchema/golden/input_put_nonstandard.json create mode 100644 tests/Functional/JsonSchema/golden/input_query.json create mode 100644 tests/Functional/JsonSchema/golden/input_validation_groups.json create mode 100644 tests/Functional/JsonSchema/golden/jsonapi_input_post.json create mode 100644 tests/Functional/JsonSchema/golden/jsonapi_output_item.json create mode 100644 tests/Functional/JsonSchema/golden/jsonapi_relation_iri_vs_embed.json create mode 100644 tests/Functional/JsonSchema/golden/jsonhal_output_item.json create mode 100644 tests/Functional/JsonSchema/golden/jsonhal_relation_iri_vs_embed.json create mode 100644 tests/Functional/JsonSchema/golden/jsonld_gen_id_false.json create mode 100644 tests/Functional/JsonSchema/golden/jsonld_input_post.json create mode 100644 tests/Functional/JsonSchema/golden/jsonld_output_collection.json create mode 100644 tests/Functional/JsonSchema/golden/jsonld_output_item.json create mode 100644 tests/Functional/JsonSchema/golden/jsonld_relation_iri_vs_embed.json create mode 100644 tests/Functional/JsonSchema/golden/mcp_tool_output.json create mode 100644 tests/Functional/JsonSchema/golden/multi_resource_nested_input.json create mode 100644 tests/Functional/JsonSchema/golden/multi_resource_nested_output.json create mode 100644 tests/Functional/JsonSchema/golden/non_resource_class.json create mode 100644 tests/Functional/JsonSchema/golden/output_attribute_selected_relation.json create mode 100644 tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json create mode 100644 tests/Functional/JsonSchema/golden/output_collection.json create mode 100644 tests/Functional/JsonSchema/golden/output_definition_name.json create mode 100644 tests/Functional/JsonSchema/golden/output_dto.json create mode 100644 tests/Functional/JsonSchema/golden/output_gen_id_false.json create mode 100644 tests/Functional/JsonSchema/golden/output_ignored_attributes.json create mode 100644 tests/Functional/JsonSchema/golden/output_item.json create mode 100644 tests/Functional/JsonSchema/golden/output_nested_attributes.json create mode 100644 tests/Functional/JsonSchema/golden/output_relation_iri_vs_embed.json diff --git a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php new file mode 100644 index 0000000000..703bda2a7c --- /dev/null +++ b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php @@ -0,0 +1,256 @@ + + * + * 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\Generator; + +use ApiPlatform\JsonSchema\ResourceMetadataTrait; +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionPolicyInterface; + +final class ApiPlatformDefinitionPolicy implements DefinitionPolicyInterface +{ + use ResourceMetadataTrait; + + private const GLUE = '.'; + private const JSON_MERGE_PATCH_FORMAT = 'merge-patch+json'; + private const JSON_MERGE_PATCH_SCHEMA_POSTFIX = 'jsonMergePatch'; + + /** + * @var \ArrayObject + */ + private \ArrayObject $prefixCache; + + private ?SchemaGenerationRequest $request = null; + + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + ?ResourceClassResolverInterface $resourceClassResolver, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + ) { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceClassResolver = $resourceClassResolver; + $this->prefixCache = new \ArrayObject(); + } + + public function forRequest(SchemaGenerationRequest $request): self + { + $policy = clone $this; + $policy->request = $request; + + return $policy; + } + + public function isJsonMergePatch(?Operation $operation, string $type, string $format): bool + { + return 'json' === $format && 'PATCH' === $this->resolveMethod($operation, $type) && Schema::TYPE_INPUT === $type; + } + + public function isPartialUpdate(?Operation $operation, string $type, string $format): bool + { + $isNonStandardPut = Schema::TYPE_INPUT === $type && 'PUT' === $this->resolveMethod($operation, $type) && !($operation?->getExtraProperties()['standard_put'] ?? true); + + return $this->isJsonMergePatch($operation, $type, $format) || $isNonStandardPut; + } + + public function isPartial(string $class, ?DefinitionParent $parent): bool + { + if (null === $parent) { + return $this->request->partial ?? false; + } + + $operation = $this->findNestedOperation($class); + + return null !== $operation && $this->isPartialUpdate($operation, $this->request->schemaType ?? Schema::TYPE_OUTPUT, $this->request->format ?? 'json'); + } + + public function createRootPrefix(?Operation $operation, string $className, string $inputOrOutputClass): string + { + $prefix = $operation ? $this->createPrefixFromOperation($operation) : null; + $prefix ??= $this->createPrefixFromClass($className); + + if ($className !== $inputOrOutputClass) { + $prefix .= self::GLUE.$this->createPrefixFromClass($inputOrOutputClass); + } + + return $prefix; + } + + public function nameFor(string $class, Configuration $config, ?DefinitionParent $parent = null): string + { + $isPartialUpdate = $this->isPartial($class, $parent); + + if (null === $parent) { + $prefix = $config->definitionPrefix ?? $this->createPrefixFromClass($class); + $isJsonMergePatch = self::JSON_MERGE_PATCH_FORMAT === $config->format; + } else { + $operation = $this->findNestedOperation($class); + $prefix = $this->createNestedPrefix($class, $operation); + $type = $this->request->schemaType ?? Schema::TYPE_OUTPUT; + $format = $this->request->format ?? 'json'; + $isJsonMergePatch = null !== $operation && $this->isJsonMergePatch($operation, $type, $format); + } + + $format = $config->format ?? 'json'; + if (!\in_array($format, ['json', self::JSON_MERGE_PATCH_FORMAT], true)) { + $prefix .= self::GLUE.$format; + } + + if (null !== $config->definitionName) { + $name = \sprintf('%s%s', $prefix, '' !== $config->definitionName ? '-'.$config->definitionName : ''); + } else { + $parts = []; + + if ($config->groups) { + $parts[] = implode('_', $config->groups); + } + + if ($config->attributes) { + $parts[] = $this->getAttributesAsString($config->attributes); + } + + if (null !== $validationGroups = $config->getValidationGroupNames()) { + $parts[] = 'validation'.self::GLUE.($validationGroups ? implode('_', $validationGroups) : 'none'); + } + + $name = $parts ? \sprintf('%s-%s', $prefix, implode('_', $parts)) : $prefix; + } + + if ($isPartialUpdate && !$isJsonMergePatch) { + $name .= self::GLUE.'partial'; + } + + if (false === $this->resolveGenId($parent)) { + $name .= '_noid'; + } + + if ($isJsonMergePatch) { + $name .= self::GLUE.self::JSON_MERGE_PATCH_SCHEMA_POSTFIX; + } + + return preg_replace('/[^a-zA-Z0-9.\-_]/', self::GLUE, $name); + } + + private function resolveGenId(?DefinitionParent $parent): bool + { + for (; null !== $parent; $parent = $parent->parent) { + try { + $genId = $this->propertyMetadataFactory->create($parent->class, $parent->property)->getGenId(); + } catch (PropertyNotFoundException) { + continue; + } + + if (null !== $genId) { + return $genId; + } + } + + return true; + } + + private function findNestedOperation(string $class): ?Operation + { + if (!$this->isResourceClass($class)) { + return null; + } + + return $this->findOperation($class, $this->request->schemaType ?? Schema::TYPE_OUTPUT, null, [SchemaFactoryInterface::FORCE_SUBSCHEMA => true]); + } + + private function createNestedPrefix(string $class, ?Operation $operation): string + { + if (null === $operation) { + return $this->createPrefixFromClass($class); + } + + return $this->createPrefixFromOperation($operation) ?? $this->createPrefixFromClass($class); + } + + private function resolveMethod(?Operation $operation, string $type): string + { + if ($operation instanceof HttpOperation) { + return $operation->getMethod(); + } + + return !$operation && Schema::TYPE_INPUT === $type ? 'POST' : 'GET'; + } + + private function createPrefixFromOperation(Operation $operation): ?string + { + $name = $operation->getShortName(); + + if (null === $name) { + return null; + } + + if (!isset($this->prefixCache[$name])) { + $this->prefixCache[$name] = $operation->getClass(); + + return $name; + } + + if ($this->prefixCache[$name] === $operation->getClass()) { + return $name; + } + + return null; + } + + private function createPrefixFromClass(string $fullyQualifiedClassName, int $namespaceParts = 1): string + { + $parts = explode('\\', $fullyQualifiedClassName); + $name = implode(self::GLUE, \array_slice($parts, -$namespaceParts)); + + if (!isset($this->prefixCache[$name])) { + $this->prefixCache[$name] = $fullyQualifiedClassName; + + return $name; + } + + if ($this->prefixCache[$name] !== $fullyQualifiedClassName) { + $name = $this->createPrefixFromClass($fullyQualifiedClassName, ++$namespaceParts); + } + + return $name; + } + + /** + * @param array $attributes + */ + private function getAttributesAsString(array $attributes): string + { + $parts = []; + + foreach ($attributes as $key => $value) { + if (\is_array($value)) { + foreach (explode('_', $this->getAttributesAsString($value)) as $child) { + $parts[] = $key.self::GLUE.$child; + } + } elseif (\is_string($key)) { + $parts[] = $key; + } else { + $parts[] = $value; + } + } + + return implode('_', $parts); + } +} diff --git a/src/JsonSchema/Generator/ApiPlatformPropertyInfoExtractor.php b/src/JsonSchema/Generator/ApiPlatformPropertyInfoExtractor.php new file mode 100644 index 0000000000..7203fae18b --- /dev/null +++ b/src/JsonSchema/Generator/ApiPlatformPropertyInfoExtractor.php @@ -0,0 +1,78 @@ + + * + * 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\Generator; + +use ApiPlatform\Metadata\ApiProperty; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; + +final class ApiPlatformPropertyInfoExtractor implements PropertyInfoExtractorInterface, PropertyInitializableExtractorInterface +{ + public function __construct( + private readonly PropertyMetadataAccessor $accessor, + ) { + } + + public function getProperties(string $class, array $context = []): array + { + return $this->accessor->getNames($class, $context); + } + + public function getType(string $class, string $property, array $context = []): ?Type + { + $metadata = $this->accessor->get($class, $property, $context); + + if (null === $type = $metadata->getNativeType()) { + return null; + } + + if ($type instanceof CollectionType && null !== $metadata->getUriTemplate()) { + $type = $type->getCollectionValueType(); + } + + return $type; + } + + public function isReadable(string $class, string $property, array $context = []): bool + { + return $this->isExposed($this->accessor->get($class, $property, $context)); + } + + public function isWritable(string $class, string $property, array $context = []): bool + { + return $this->isExposed($this->accessor->get($class, $property, $context)); + } + + public function isInitializable(string $class, string $property, array $context = []): bool + { + return $this->isExposed($this->accessor->get($class, $property, $context)); + } + + public function getShortDescription(string $class, string $property, array $context = []): ?string + { + return null; + } + + public function getLongDescription(string $class, string $property, array $context = []): ?string + { + return null; + } + + private function isExposed(ApiProperty $metadata): bool + { + return false !== $metadata->isReadable() || false !== $metadata->isWritable(); + } +} diff --git a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php new file mode 100644 index 0000000000..13b4d66bd6 --- /dev/null +++ b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php @@ -0,0 +1,98 @@ + + * + * 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\Generator; + +use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\Enricher\PropertySchema; +use Symfony\Component\JsonSchema\Enricher\PropertySchemaEnricherInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; + +final class ApiPropertySchemaEnricher implements PropertySchemaEnricherInterface +{ + private const STRUCTURAL_KEYWORDS = ['type', 'items', 'additionalProperties', 'anyOf', 'allOf', 'oneOf', '$ref', 'enum', 'format', 'const']; + private const OWNED_KEYWORDS = ['type', 'items', 'additionalProperties', 'anyOf', 'allOf', 'oneOf', '$ref', 'readOnly', 'writeOnly', 'description', 'deprecated', 'externalDocs', 'default', 'example']; + + public function __construct( + private readonly PropertyMetadataAccessor $accessor, + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly string $version, + ) { + } + + public function enrich(PropertySchema $property, Configuration $config): PropertySchema + { + $metadata = $this->accessor->get($property->class, $property->property, PropertyMetadataAccessor::contextFromConfiguration($config)); + $required = true === $metadata->isRequired(); + + if (true === ($metadata->getExtraProperties()[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) { + return $property->withRequired($required); + } + + $contextSchema = Schema::VERSION_JSON_SCHEMA === $this->version ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); + $schema = [...$property->schema, ...array_diff_key($metadata->getSchema() ?? [], array_flip(self::OWNED_KEYWORDS)), ...$this->getDocumentation($metadata)]; + + if ($contextSchema) { + if (array_intersect_key($contextSchema, array_flip(self::STRUCTURAL_KEYWORDS))) { + $schema = array_diff_key($schema, array_flip(self::STRUCTURAL_KEYWORDS)); + } + $schema = [...$schema, ...$contextSchema]; + } + + return $property->withSchema($schema)->withRequired($required); + } + + /** + * @return array + */ + private function getDocumentation(ApiProperty $metadata): array + { + $documentation = []; + + if (null !== $metadata->getUriTemplate() || (false === $metadata->isWritable() && !$metadata->isInitializable())) { + $documentation['readOnly'] = true; + } + + if (false === $metadata->isReadable()) { + $documentation['writeOnly'] = true; + } + + if (null !== $description = $metadata->getDescription()) { + $documentation['description'] = $description; + } + + if (null !== $metadata->getDeprecationReason()) { + $documentation['deprecated'] = true; + } + + if (null !== $iri = $metadata->getTypes()[0] ?? null) { + $documentation['externalDocs'] = ['url' => $iri]; + } + + $isResourceClass = $metadata->getNativeType()?->isSatisfiedBy(fn (Type $type): bool => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($type->getClassName())); + if (null !== ($default = $metadata->getDefault()) && [] !== $default && !$isResourceClass) { + $documentation['default'] = $default instanceof \BackedEnum ? $default->value : $default; + } + + if (null !== ($example = $metadata->getExample()) && [] !== $example) { + $documentation['example'] = $example; + } + + return $documentation; + } +} diff --git a/src/JsonSchema/Generator/ApiPropertySchemaProvider.php b/src/JsonSchema/Generator/ApiPropertySchemaProvider.php new file mode 100644 index 0000000000..a887cebd94 --- /dev/null +++ b/src/JsonSchema/Generator/ApiPropertySchemaProvider.php @@ -0,0 +1,41 @@ + + * + * 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\Generator; + +use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; +use ApiPlatform\JsonSchema\Schema; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\Enricher\PropertySchemaProviderInterface; + +final class ApiPropertySchemaProvider implements PropertySchemaProviderInterface +{ + public function __construct( + private readonly PropertyMetadataAccessor $accessor, + private readonly string $version, + ) { + } + + public function provide(string $class, string $property, Configuration $config): ?array + { + $metadata = $this->accessor->get($class, $property, PropertyMetadataAccessor::contextFromConfiguration($config)); + + if (true !== ($metadata->getExtraProperties()[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) { + return null; + } + + $contextSchema = Schema::VERSION_JSON_SCHEMA === $this->version ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); + + return array_merge($metadata->getSchema() ?? [], $contextSchema ?? []); + } +} diff --git a/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php b/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php new file mode 100644 index 0000000000..e769a421ab --- /dev/null +++ b/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php @@ -0,0 +1,78 @@ + + * + * 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\Generator; + +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; +use Symfony\Component\JsonSchema\NullSyntax; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\NullableType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +final class BuiltinTypeDefinitionProcessor implements DefinitionProcessorInterface +{ + private const STRUCTURAL_KEYWORDS = ['type', 'items', 'additionalProperties', 'anyOf', 'allOf', 'oneOf', '$ref', 'enum', 'const']; + + public function __construct( + private readonly PropertyMetadataAccessor $accessor, + private readonly ?NameConverterInterface $nameConverter = null, + ) { + } + + public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + { + if (!isset($definition['properties'])) { + return $definition; + } + + $context = PropertyMetadataAccessor::contextFromConfiguration($config); + $nameConverterContext = array_filter(['groups' => $config->groups, 'attributes' => $config->attributes], static fn (?array $value): bool => null !== $value && [] !== $value); + $string = self::nullableString($config->dialect->nullSyntax); + + foreach ($this->accessor->getNames($class, $context) as $property) { + $name = $this->nameConverter?->normalize($property, $class, $config->format, $nameConverterContext) ?? $property; + if (!isset($definition['properties'][$name])) { + continue; + } + + $type = $this->accessor->get($class, $property, $context)->getNativeType(); + while ($type instanceof NullableType) { + $type = $type->getWrappedType(); + } + + $schema = $definition['properties'][$name]; + if ($type?->isIdentifiedBy(TypeIdentifier::MIXED) && !array_intersect_key($schema, array_flip(self::STRUCTURAL_KEYWORDS))) { + $definition['properties'][$name] = [...$string, ...$schema]; + } elseif ($type instanceof CollectionType && $type->getCollectionValueType()->isIdentifiedBy(TypeIdentifier::MIXED) && [] === ($schema['items'] ?? null)) { + $definition['properties'][$name]['items'] = $string; + } + } + + return $definition; + } + + /** + * @return array + */ + private static function nullableString(NullSyntax $syntax): array + { + return match ($syntax) { + NullSyntax::Union => ['type' => ['string', 'null']], + NullSyntax::NullableFlag => ['type' => 'string', 'nullable' => true], + NullSyntax::Unsupported => ['type' => 'string'], + }; + } +} diff --git a/src/JsonSchema/Generator/ConfigurationFactory.php b/src/JsonSchema/Generator/ConfigurationFactory.php new file mode 100644 index 0000000000..509c01d58b --- /dev/null +++ b/src/JsonSchema/Generator/ConfigurationFactory.php @@ -0,0 +1,144 @@ + + * + * 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\Generator; + +use ApiPlatform\JsonSchema\ResourceMetadataTrait; +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\Dialect; +use Symfony\Component\JsonSchema\NullSyntax; +use Symfony\Component\JsonSchema\ReferenceStrategy; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\Validator\Constraints\GroupSequence; + +final class ConfigurationFactory +{ + use ResourceMetadataTrait; + + private const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; + + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + ?ResourceClassResolverInterface $resourceClassResolver, + private readonly ApiPlatformDefinitionPolicy $definitionPolicy, + ) { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceClassResolver = $resourceClassResolver; + } + + /** + * @param array|null $serializerContext + */ + public function create(string $className, string $format, string $type, ?Operation $operation, ?array $serializerContext, string $version, bool $forceCollection = false): ?SchemaGenerationRequest + { + $operationWasProvided = null !== $operation; + + if (!$this->isResourceClass($className)) { + $serializerContext ??= $operation ? $this->getSerializerContext($operation, $type) : []; + $operation = null; + $inputOrOutputClass = $className; + } else { + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); + $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); + if ($operationWasProvided || !($serializerContext[SchemaFactoryInterface::FORCE_SUBSCHEMA] ?? false)) { + $serializerContext = ($serializerContext ?? []) + $this->getSerializerContext($operation, $type); + } + } + + if (null === $inputOrOutputClass) { + return null; + } + + $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; + if (!$operation) { + $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET'; + } + + if (!($serializerContext[SchemaFactoryInterface::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT', 'QUERY'], true) && true !== $operation?->canDeserialize()) { + return null; + } + + $isJsonMergePatch = $this->definitionPolicy->isJsonMergePatch($operation, $type, $format); + + $validationGroups = $operation?->getValidationContext()['groups'] ?? null; + $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES] ?? null; + + $configuration = new Configuration( + $this->createDialect($version), + ReferenceStrategy::ByDefinition, + array_values((array) ($serializerContext[AbstractNormalizer::GROUPS] ?? [])), + $attributes ? (array) $attributes : null, + array_values((array) ($serializerContext['ignored_attributes'] ?? [])), + false !== ($serializerContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] ?? true), + $this->normalizeValidationGroups($validationGroups), + $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null, + $this->definitionPolicy->createRootPrefix($operation, $className, $inputOrOutputClass), + $isJsonMergePatch ? 'merge-patch+json' : $format, + ); + + $propertyOptions = ['schema_type' => $type, 'enable_getter_setter_extraction' => true]; + if (null !== $validationGroups) { + $propertyOptions['validation_groups'] = \is_array($validationGroups) ? $validationGroups : [$validationGroups]; + } + if ($ignoredAttributes = $serializerContext['ignored_attributes'] ?? null) { + $propertyOptions['ignored_attributes'] = $ignoredAttributes; + } + + $objectType = Type::object($inputOrOutputClass); + $isCollection = $forceCollection || (!\in_array($method, ['POST', 'QUERY'], true) && $operation instanceof CollectionOperationInterface); + + return new SchemaGenerationRequest( + $isCollection ? Type::list($objectType) : $objectType, + $configuration, + $type, + $format, + $this->definitionPolicy->isPartialUpdate($operation, $type, $format), + $version, + $propertyOptions, + $operation?->getDescription() ?: null, + Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason() && ($operationWasProvided || !($serializerContext[SchemaFactoryInterface::FORCE_SUBSCHEMA] ?? false)), + $operation instanceof HttpOperation ? ($operation->getTypes()[0] ?? null) : null, + ); + } + + private function createDialect(string $version): Dialect + { + return match ($version) { + Schema::VERSION_OPENAPI => new Dialect('#/components/schemas/', NullSyntax::Union), + Schema::VERSION_SWAGGER => new Dialect('#/definitions/', NullSyntax::Union, supportsDeprecated: false), + default => new Dialect('#/definitions/', NullSyntax::Union, schemaUri: 'http://json-schema.org/draft-07/schema#'), + }; + } + + /** + * @return array|GroupSequence|\Closure|null + */ + private function normalizeValidationGroups(mixed $groups): array|GroupSequence|\Closure|null + { + return match (true) { + null === $groups => null, + $groups instanceof GroupSequence, $groups instanceof \Closure => $groups, + \is_array($groups) && \is_callable($groups) => \Closure::fromCallable($groups), + \is_array($groups) => $groups, + default => [$groups], + }; + } +} diff --git a/src/JsonSchema/Generator/GeneratorSchemaFactory.php b/src/JsonSchema/Generator/GeneratorSchemaFactory.php new file mode 100644 index 0000000000..3a3f18ed52 --- /dev/null +++ b/src/JsonSchema/Generator/GeneratorSchemaFactory.php @@ -0,0 +1,89 @@ + + * + * 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\Generator; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Symfony\Component\JsonSchema\ClassSchemaResolver\NativeClassSchemaResolver; +use Symfony\Component\JsonSchema\ClassSchemaResolver\UidClassSchemaResolver; +use Symfony\Component\JsonSchema\SchemaGenerator; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +final class GeneratorSchemaFactory implements SchemaFactoryInterface +{ + public function __construct( + private readonly ConfigurationFactory $configurationFactory, + private readonly ApiPlatformDefinitionPolicy $definitionPolicy, + private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly ?NameConverterInterface $nameConverter = null, + ) { + } + + public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema + { + $schema = $schema ? clone $schema : new Schema(); + + if (null === $request = $this->configurationFactory->create($className, $format, $type, $operation, $serializerContext, $schema->getVersion(), $forceCollection)) { + return $schema; + } + + $policy = $this->definitionPolicy->forRequest($request); + $accessor = new PropertyMetadataAccessor($this->propertyNameCollectionFactory, $this->propertyMetadataFactory, $request->propertyOptions); + $generator = new SchemaGenerator( + new ApiPlatformPropertyInfoExtractor($accessor), + $policy, + [new ResourceIriClassSchemaResolver($accessor, $this->resourceClassResolver, $type), new NativeClassSchemaResolver(), new UidClassSchemaResolver()], + [new ApiPropertySchemaProvider($accessor, $request->version)], + [new ApiPropertySchemaEnricher($accessor, $this->resourceClassResolver, $request->version)], + [new BuiltinTypeDefinitionProcessor($accessor, $this->nameConverter), new PartialUpdateDefinitionProcessor($policy)], + $this->nameConverter, + ); + + $generated = $generator->generate($request->type, $request->configuration); + $refPath = $request->configuration->dialect->refPath; + + $root = $generated->getRoot(); + $rootName = $root['items']['$ref'] ?? $root['$ref'] ?? null; + $rootName = \is_string($rootName) && str_starts_with($rootName, $refPath) ? substr($rootName, \strlen($refPath)) : null; + + foreach ($root as $keyword => $value) { + $schema[$keyword] = $value; + } + + $definitions = $schema->getDefinitions(); + foreach ($generated->getDefinitions() as $name => $definition) { + if ($name === $rootName) { + $definition += array_filter([ + 'description' => $request->description, + 'deprecated' => $request->deprecated ?: null, + 'externalDocs' => $request->externalDocs ? ['url' => $request->externalDocs] : null, + ]); + } + + foreach ($definition['properties'] ?? [] as $property => $propertySchema) { + $definition['properties'][$property] = new \ArrayObject($propertySchema); + } + + $definitions[$name] = new \ArrayObject($definition); + } + + return $schema; + } +} diff --git a/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php b/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php new file mode 100644 index 0000000000..99a353334a --- /dev/null +++ b/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php @@ -0,0 +1,34 @@ + + * + * 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\Generator; + +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; + +final class PartialUpdateDefinitionProcessor implements DefinitionProcessorInterface +{ + public function __construct(private readonly ApiPlatformDefinitionPolicy $definitionPolicy) + { + } + + public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + { + if ($this->definitionPolicy->isPartial($class, $parent)) { + unset($definition['required']); + } + + return $definition; + } +} diff --git a/src/JsonSchema/Generator/PropertyMetadataAccessor.php b/src/JsonSchema/Generator/PropertyMetadataAccessor.php new file mode 100644 index 0000000000..6e64941661 --- /dev/null +++ b/src/JsonSchema/Generator/PropertyMetadataAccessor.php @@ -0,0 +1,73 @@ + + * + * 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\Generator; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use Symfony\Component\JsonSchema\Configuration; + +final class PropertyMetadataAccessor +{ + /** + * @var array + */ + private array $properties = []; + + /** + * @param array $baseOptions + */ + public function __construct( + private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + private readonly array $baseOptions, + ) { + } + + /** + * @return array + */ + public static function contextFromConfiguration(Configuration $configuration): array + { + $context = []; + if ($configuration->groups) { + $context['serializer_groups'] = $configuration->groups; + } + if (null !== $configuration->attributes) { + $context['serializer_attributes'] = $configuration->attributes; + } + + return $context; + } + + /** + * @param class-string $class + * @param array $context + * + * @return list + */ + public function getNames(string $class, array $context): array + { + return array_values(iterator_to_array($this->propertyNameCollectionFactory->create($class, $this->baseOptions + $context), false)); + } + + /** + * @param class-string $class + * @param array $context + */ + public function get(string $class, string $property, array $context): ApiProperty + { + return $this->properties[$class.'::'.$property.'::'.json_encode($context, \JSON_THROW_ON_ERROR)] ??= $this->propertyMetadataFactory->create($class, $property, $this->baseOptions + $context); + } +} diff --git a/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php b/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php new file mode 100644 index 0000000000..9c75239dc3 --- /dev/null +++ b/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php @@ -0,0 +1,65 @@ + + * + * 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\Generator; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Symfony\Component\JsonSchema\ClassSchemaResolver\ClassSchemaResolverInterface; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; + +final class ResourceIriClassSchemaResolver implements ClassSchemaResolverInterface +{ + public function __construct( + private readonly PropertyMetadataAccessor $accessor, + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly string $schemaType, + ) { + } + + public function resolve(string $class, Configuration $config, ?DefinitionParent $parent = null): ?array + { + if (null === $parent || !$this->resourceClassResolver->isResourceClass($class)) { + return null; + } + + $metadata = $this->accessor->get($parent->class, $parent->property, PropertyMetadataAccessor::contextFromConfiguration($parent->config)); + + if ($this->isLink($parent->class, $metadata)) { + return null; + } + + return ['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/']; + } + + private function isLink(string $class, ApiProperty $metadata): bool + { + $isInput = Schema::TYPE_INPUT === $this->schemaType; + $link = $isInput ? $metadata->isWritableLink() : ($metadata->isReadableLink() || false === $metadata->getGenId()); + + if (!$isInput && !$this->resourceClassResolver->isResourceClass($class) && !$metadata->getNativeType()?->isSatisfiedBy($this->isResourceType(...))) { + return true; + } + + return $link ?? true; + } + + private function isResourceType(Type $type): bool + { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($type->getClassName()); + } +} diff --git a/src/JsonSchema/Generator/SchemaGenerationRequest.php b/src/JsonSchema/Generator/SchemaGenerationRequest.php new file mode 100644 index 0000000000..07beb9c3a4 --- /dev/null +++ b/src/JsonSchema/Generator/SchemaGenerationRequest.php @@ -0,0 +1,37 @@ + + * + * 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\Generator; + +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\TypeInfo\Type; + +final readonly class SchemaGenerationRequest +{ + /** + * @param array $propertyOptions + */ + public function __construct( + public Type $type, + public Configuration $configuration, + public string $schemaType, + public string $format, + public bool $partial, + public string $version, + public array $propertyOptions, + public ?string $description = null, + public bool $deprecated = false, + public ?string $externalDocs = null, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedAuthor.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedAuthor.php new file mode 100644 index 0000000000..0ff7e74a79 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedAuthor.php @@ -0,0 +1,43 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'AttributeSelectedAuthor', + operations: [ + new Get( + uriTemplate: '/attribute_selected_authors/{id}', + name: 'attribute_selected_author_get', + provider: [self::class, 'provide'], + ), + ], +)] +final class AttributeSelectedAuthor +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + public string $email = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedBook.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedBook.php new file mode 100644 index 0000000000..7b912202c8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/AttributeSelectedBook.php @@ -0,0 +1,58 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'AttributeSelectedBook', + operations: [ + new Get( + uriTemplate: '/attribute_selected_books/{id}', + name: 'attribute_selected_book_get', + normalizationContext: ['attributes' => ['title', 'author' => ['name']]], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/attribute_selected_books', + name: 'attribute_selected_book_post', + denormalizationContext: ['attributes' => ['title', 'author' => ['name']]], + processor: [self::class, 'process'], + ), + ], +)] +final class AttributeSelectedBook +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $title = ''; + + public string $isbn = ''; + + public ?AttributeSelectedAuthor $author = null; + + public static function provide(): null + { + return null; + } + + public static function process(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/BuiltinTypeQuirks.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/BuiltinTypeQuirks.php new file mode 100644 index 0000000000..5930313edd --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/BuiltinTypeQuirks.php @@ -0,0 +1,35 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use BcMath\Number; + +#[ApiResource( + shortName: 'BuiltinTypeQuirks', + operations: [ + new Get(uriTemplate: '/builtin_type_quirks/{id}', name: 'builtin_type_quirks_get'), + ], +)] +final class BuiltinTypeQuirks +{ + public ?int $id = null; + + public mixed $anything = null; + + public array $untyped = []; + + public ?Number $amount = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenAuthor.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenAuthor.php new file mode 100644 index 0000000000..fa33e6e282 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenAuthor.php @@ -0,0 +1,25 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use Symfony\Component\Serializer\Attribute\Groups; + +final class GoldenAuthor +{ + #[Groups(['golden:read'])] + public string $name = ''; + + #[Groups(['golden:read'])] + public string $email = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenBook.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenBook.php new file mode 100644 index 0000000000..7707fb37ec --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenBook.php @@ -0,0 +1,61 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenBook', + operations: [ + new Get( + uriTemplate: '/golden_books/{id}', + name: 'golden_book_get', + description: 'Retrieves a golden book.', + deprecationReason: 'Use another book endpoint.', + types: ['https://schema.org/Book'], + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/golden_books', + name: 'golden_book_get_collection', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenBook +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[Groups(['golden:read'])] + public string $title = ''; + + #[ApiProperty(description: 'International standard book number.', deprecationReason: 'Use title.', example: '9780000000000')] + #[Groups(['golden:read'])] + public ?string $isbn = null; + + public string $internalNote = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenGroupedItem.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenGroupedItem.php new file mode 100644 index 0000000000..70a45b1ff6 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenGroupedItem.php @@ -0,0 +1,52 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenGroupedItem', + operations: [ + new Get( + uriTemplate: '/golden_grouped_items/{id}', + name: 'golden_grouped_item_get', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenGroupedItem +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[Groups(['golden:read', 'golden:write'])] + public string $title = ''; + + #[Groups(['golden:read'])] + public ?string $createdAt = null; + + #[Groups(['golden:write'])] + public ?string $secretToken = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenInputResource.php new file mode 100644 index 0000000000..83fd6d1144 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenInputResource.php @@ -0,0 +1,78 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'GoldenInput', + operations: [ + new Post( + uriTemplate: '/golden_inputs', + name: 'golden_input_post', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + processor: [self::class, 'process'], + ), + new Patch( + uriTemplate: '/golden_inputs/{id}', + name: 'golden_input_patch', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + provider: [self::class, 'process'], + processor: [self::class, 'process'], + ), + new Put( + uriTemplate: '/golden_inputs_put/{id}', + name: 'golden_input_put_nonstandard', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + extraProperties: ['standard_put' => false], + provider: [self::class, 'process'], + processor: [self::class, 'process'], + ), + ], +)] +final class GoldenInputResource +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[Assert\NotBlank] + #[Groups(['golden:read', 'golden:write'])] + public string $title = ''; + + #[Groups(['golden:read'])] + public ?string $createdAt = null; + + #[ApiProperty(writable: false)] + #[Groups(['golden:write'])] + public ?string $computedLabel = null; + + #[ApiProperty(readable: false)] + #[Groups(['golden:write'])] + public ?string $password = null; + + public static function process(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceEntity.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceEntity.php new file mode 100644 index 0000000000..c492a316a9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceEntity.php @@ -0,0 +1,64 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenAdminMultiResource', + operations: [ + new Get( + uriTemplate: '/golden_admin_multi_resources/{id}', + name: 'golden_admin_multi_resource_get', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + ], +)] +#[ApiResource( + shortName: 'GoldenMultiResource', + operations: [ + new Get( + uriTemplate: '/golden_multi_resources/{id}', + name: 'golden_multi_resource_get', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/golden_multi_resources', + name: 'golden_multi_resource_post', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + processor: [self::class, 'provide'], + ), + ], +)] +final class GoldenMultiResourceEntity +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[Groups(['golden:read', 'golden:write'])] + public string $label = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceOwner.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceOwner.php new file mode 100644 index 0000000000..a37e59b3f2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenMultiResourceOwner.php @@ -0,0 +1,54 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenMultiResourceOwner', + operations: [ + new Get( + uriTemplate: '/golden_multi_resource_owners/{id}', + name: 'golden_multi_resource_owner_get', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/golden_multi_resource_owners', + name: 'golden_multi_resource_owner_post', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + processor: [self::class, 'provide'], + ), + ], +)] +final class GoldenMultiResourceOwner +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[ApiProperty(readableLink: true, writableLink: true)] + #[Groups(['golden:read', 'golden:write'])] + public ?GoldenMultiResourceEntity $entity = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNestedResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNestedResource.php new file mode 100644 index 0000000000..817a2233f2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNestedResource.php @@ -0,0 +1,52 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'GoldenNested', + operations: [ + new Get( + uriTemplate: '/golden_nested/{id}', + name: 'golden_nested_get_attributes', + normalizationContext: ['attributes' => ['name', 'author' => ['name']]], + provider: [self::class, 'provide'], + ), + new Get( + uriTemplate: '/golden_nested_ignored/{id}', + name: 'golden_nested_get_ignored', + normalizationContext: ['ignored_attributes' => ['secret']], + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenNestedResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + public string $secret = ''; + + public ?GoldenAuthor $author = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNonStandardPutChild.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNonStandardPutChild.php new file mode 100644 index 0000000000..7632772d07 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenNonStandardPutChild.php @@ -0,0 +1,56 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Put; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenNonStandardPutChild', + operations: [ + new Get( + uriTemplate: '/golden_non_standard_put_children/{id}', + name: 'golden_non_standard_put_child_get', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + new Put( + uriTemplate: '/golden_non_standard_put_children/{id}', + name: 'golden_non_standard_put_child_put', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + extraProperties: ['standard_put' => false], + provider: [self::class, 'provide'], + processor: [self::class, 'provide'], + ), + ], +)] +final class GoldenNonStandardPutChild +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[ApiProperty(required: true)] + #[Groups(['golden:read', 'golden:write'])] + public string $label = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDto.php new file mode 100644 index 0000000000..e0537438c3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDto.php @@ -0,0 +1,21 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +final class GoldenOutputDto +{ + public string $label = ''; + + public int $total = 0; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoResource.php new file mode 100644 index 0000000000..ee63a3658b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoResource.php @@ -0,0 +1,40 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'GoldenOutputDtoResource', + operations: [ + new Get( + uriTemplate: '/golden_output_dtos/{id}', + name: 'golden_output_dto_get', + output: GoldenOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenOutputDtoResource +{ + public ?int $id = null; + + public string $internal = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPartialChildrenOwner.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPartialChildrenOwner.php new file mode 100644 index 0000000000..31fd7129e8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPartialChildrenOwner.php @@ -0,0 +1,62 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenPartialChildrenOwner', + operations: [ + new Get( + uriTemplate: '/golden_partial_children_owners/{id}', + name: 'golden_partial_children_owner_get', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/golden_partial_children_owners', + name: 'golden_partial_children_owner_post', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + processor: [self::class, 'provide'], + ), + ], +)] +final class GoldenPartialChildrenOwner +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[ApiProperty(required: true)] + #[Groups(['golden:read', 'golden:write'])] + public string $name = ''; + + #[ApiProperty(readableLink: true, writableLink: true)] + #[Groups(['golden:read', 'golden:write'])] + public ?GoldenPatchOnlyChild $patchChild = null; + + #[ApiProperty(readableLink: true, writableLink: true)] + #[Groups(['golden:read', 'golden:write'])] + public ?GoldenNonStandardPutChild $putChild = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPatchOnlyChild.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPatchOnlyChild.php new file mode 100644 index 0000000000..25e49115d8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPatchOnlyChild.php @@ -0,0 +1,55 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Patch; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenPatchOnlyChild', + operations: [ + new Get( + uriTemplate: '/golden_patch_only_children/{id}', + name: 'golden_patch_only_child_get', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + new Patch( + uriTemplate: '/golden_patch_only_children/{id}', + name: 'golden_patch_only_child_patch', + normalizationContext: ['groups' => ['golden:read']], + denormalizationContext: ['groups' => ['golden:write']], + provider: [self::class, 'provide'], + processor: [self::class, 'provide'], + ), + ], +)] +final class GoldenPatchOnlyChild +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[ApiProperty(required: true)] + #[Groups(['golden:read', 'golden:write'])] + public string $label = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPlainObject.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPlainObject.php new file mode 100644 index 0000000000..9105e1190b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenPlainObject.php @@ -0,0 +1,26 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use Symfony\Component\Serializer\Attribute\Groups; + +final class GoldenPlainObject +{ + public string $name = ''; + + public ?int $count = null; + + #[Groups(['golden:read'])] + public bool $flag = false; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryCriteria.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryCriteria.php new file mode 100644 index 0000000000..464e20ca64 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryCriteria.php @@ -0,0 +1,24 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use Symfony\Component\Validator\Constraints as Assert; + +final class GoldenQueryCriteria +{ + #[Assert\NotBlank] + public string $term = ''; + + public ?int $limit = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryResource.php new file mode 100644 index 0000000000..fe5a81cf6e --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenQueryResource.php @@ -0,0 +1,43 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Query; + +#[ApiResource( + shortName: 'GoldenQuery', + operations: [ + new Query( + uriTemplate: '/golden_query', + name: 'golden_query', + input: GoldenQueryCriteria::class, + read: false, + deserialize: true, + write: true, + processor: [self::class, 'process'], + ), + ], +)] +final class GoldenQueryResource +{ + public ?int $id = null; + + public string $title = ''; + + public static function process(mixed $data): mixed + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelated.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelated.php new file mode 100644 index 0000000000..935db35e50 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelated.php @@ -0,0 +1,45 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenRelated', + operations: [ + new Get( + uriTemplate: '/golden_related/{id}', + name: 'golden_related_get', + normalizationContext: ['groups' => ['golden:read']], + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenRelated +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read'])] + public ?int $id = null; + + #[Groups(['golden:read', 'golden:embed', 'golden:noid'])] + public string $label = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelationResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelationResource.php new file mode 100644 index 0000000000..6f41bcd254 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenRelationResource.php @@ -0,0 +1,60 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'GoldenRelation', + operations: [ + new Get( + uriTemplate: '/golden_relations/{id}', + name: 'golden_relation_get', + normalizationContext: ['groups' => ['golden:read', 'golden:embed']], + provider: [self::class, 'provide'], + ), + new Get( + uriTemplate: '/golden_relations_noid/{id}', + name: 'golden_relation_get_noid', + normalizationContext: ['groups' => ['golden:noid']], + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenRelationResource +{ + #[ApiProperty(identifier: true)] + #[Groups(['golden:read', 'golden:noid'])] + public ?int $id = null; + + #[ApiProperty(readableLink: false)] + #[Groups(['golden:read'])] + public ?GoldenRelated $linked = null; + + #[ApiProperty(readableLink: true)] + #[Groups(['golden:embed'])] + public ?GoldenRelated $embedded = null; + + #[ApiProperty(readableLink: true, genId: false)] + #[Groups(['golden:noid'])] + public ?GoldenRelated $embeddedWithoutId = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenStrictInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenStrictInputResource.php new file mode 100644 index 0000000000..3e5b217e76 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenStrictInputResource.php @@ -0,0 +1,40 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'GoldenStrictInput', + operations: [ + new Post( + uriTemplate: '/golden_strict_inputs', + name: 'golden_strict_input_post', + denormalizationContext: ['allow_extra_attributes' => false], + processor: [self::class, 'process'], + ), + ], +)] +final class GoldenStrictInputResource +{ + public string $name = ''; + + public ?int $quantity = null; + + public static function process(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenValidatedResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenValidatedResource.php new file mode 100644 index 0000000000..03e2ba79ee --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenValidatedResource.php @@ -0,0 +1,49 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'GoldenValidated', + operations: [ + new Post( + uriTemplate: '/golden_validated', + name: 'golden_validated_post', + denormalizationContext: ['groups' => ['golden:write']], + validationContext: ['groups' => ['golden:create']], + processor: [self::class, 'process'], + ), + ], +)] +final class GoldenValidatedResource +{ + #[Assert\NotBlank(groups: ['golden:create'])] + #[Assert\Length(max: 20, groups: ['golden:create'])] + #[Groups(['golden:write'])] + public string $name = ''; + + #[Assert\NotBlank(groups: ['golden:other'])] + #[Assert\Length(min: 3, groups: ['golden:other'])] + #[Groups(['golden:write'])] + public string $reference = ''; + + public static function process(): null + { + return null; + } +} diff --git a/tests/Functional/JsonSchema/GoldenSchemaTest.php b/tests/Functional/JsonSchema/GoldenSchemaTest.php new file mode 100644 index 0000000000..b60df3ad7e --- /dev/null +++ b/tests/Functional/JsonSchema/GoldenSchemaTest.php @@ -0,0 +1,459 @@ + + * + * 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\Functional\JsonSchema; + +use ApiPlatform\JsonSchema\Generator\ApiPlatformDefinitionPolicy; +use ApiPlatform\JsonSchema\Generator\ConfigurationFactory; +use ApiPlatform\JsonSchema\Generator\GeneratorSchemaFactory; +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactory; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\AttributeSelectedAuthor; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\AttributeSelectedBook; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\BuiltinTypeQuirks; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenBook; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenGroupedItem; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenMultiResourceEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenMultiResourceOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenNestedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenNonStandardPutChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenOutputDtoResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenPartialChildrenOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenPatchOnlyChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenPlainObject; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenQueryResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenRelationResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenStrictInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenValidatedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatTool; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +class GoldenSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private SchemaFactoryInterface $schemaFactory; + private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory; + + protected function setUp(): void + { + parent::setUp(); + $this->schemaFactory = self::getContainer()->get('api_platform.json_schema.schema_factory'); + $this->resourceMetadataCollectionFactory = self::getContainer()->get('api_platform.metadata.resource.metadata_collection_factory'); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + AttributeSelectedAuthor::class, + AttributeSelectedBook::class, + BuiltinTypeQuirks::class, + GoldenBook::class, + GoldenGroupedItem::class, + GoldenInputResource::class, + GoldenMultiResourceEntity::class, + GoldenMultiResourceOwner::class, + GoldenNestedResource::class, + GoldenNonStandardPutChild::class, + GoldenOutputDtoResource::class, + GoldenPartialChildrenOwner::class, + GoldenPatchOnlyChild::class, + GoldenQueryResource::class, + GoldenRelated::class, + GoldenRelationResource::class, + GoldenStrictInputResource::class, + GoldenValidatedResource::class, + McpFormatTool::class, + ]; + } + + /** + * @param array|null $serializerContext + * @param array $configuration + */ + #[DataProvider('provideCases')] + public function testBuildSchemaMatchesGolden(string $case, string $className, string $format, string $type, ?string $operationName, ?array $serializerContext, string $version, array $configuration): void + { + $this->assertNotEmpty($configuration); + + $operation = null; + if ('mcp' === ($configuration['schemaFactory'] ?? null)) { + $operation = $this->resourceMetadataCollectionFactory->create($className)[0]->getMcp()[$operationName]; + } elseif (null !== $operationName) { + $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation($operationName); + } + + $factory = 'mcp' === ($configuration['schemaFactory'] ?? null) + ? self::getContainer()->get('api_platform.mcp.json_schema.schema_factory') + : $this->schemaFactory; + + $schema = $factory->buildSchema($className, $format, $type, $operation, new Schema($version), $serializerContext); + + $actual = json_encode(self::normalize([ + 'schema' => $schema->getArrayCopy(false), + 'definitions' => $schema->getDefinitions(), + ]), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR)."\n"; + + $file = __DIR__.'/golden/'.$case.'.json'; + if ('1' === getenv('UPDATE_GOLDEN')) { + file_put_contents($file, $actual); + $this->markTestIncomplete(\sprintf('Golden "%s" written.', $case)); + } + + $this->assertJsonStringEqualsJsonFile($file, $actual); + } + + /** + * @param array|null $serializerContext + * @param array $configuration + */ + #[DataProvider('provideCases')] + public function testGenerateMatchesGolden(string $case, string $className, string $format, string $type, ?string $operationName, ?array $serializerContext, string $version, array $configuration): void + { + if ('json' !== $format) { + $this->markTestSkipped('Stage 1 covers the json format only.'); + } + + if ('mcp' === ($configuration['schemaFactory'] ?? null)) { + $this->markTestSkipped('The MCP schema factory is not ported in stage 1.'); + } + + if ('groups_explicit_context_split' === $case) { + $this->markTestSkipped('Pins the explicit-context groups bug fixed in api-platform/core#8643.'); + } + + $operation = null !== $operationName ? $this->resourceMetadataCollectionFactory->create($className)->getOperation($operationName) : null; + + $container = self::getContainer(); + $metadataFactory = $container->get('api_platform.metadata.resource.metadata_collection_factory'); + $resourceClassResolver = $container->get('api_platform.resource_class_resolver'); + $propertyMetadataFactory = $container->get('api_platform.metadata.property.metadata_factory'); + $policy = new ApiPlatformDefinitionPolicy($metadataFactory, $resourceClassResolver, $propertyMetadataFactory); + $factory = new GeneratorSchemaFactory( + new ConfigurationFactory($metadataFactory, $resourceClassResolver, $policy), + $policy, + $container->get('api_platform.metadata.property.name_collection_factory'), + $propertyMetadataFactory, + $resourceClassResolver, + $container->has('api_platform.name_converter') ? $container->get('api_platform.name_converter') : null, + ); + + $schema = $factory->buildSchema($className, $format, $type, $operation, new Schema($version), $serializerContext); + + $actual = json_encode(self::normalize([ + 'schema' => $schema->getArrayCopy(false), + 'definitions' => $schema->getDefinitions(), + ]), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR)."\n"; + + $this->assertJsonStringEqualsJsonFile(__DIR__.'/golden/'.$case.'.json', $actual); + } + + /** + * @return iterable, string, array}> + */ + public static function provideCases(): iterable + { + $openApiGroups = ['groups' => ['golden:read']]; + + yield 'output_item' => ['output_item', GoldenBook::class, 'json', Schema::TYPE_OUTPUT, 'golden_book_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:read'], + 'description' => 'Retrieves a golden book.', + 'deprecated' => true, + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'output_collection' => ['output_collection', GoldenBook::class, 'json', Schema::TYPE_OUTPUT, 'golden_book_get_collection', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:read'], + 'collection' => true, + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'input_post_groups' => ['input_post_groups', GoldenInputResource::class, 'json', Schema::TYPE_INPUT, 'golden_input_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:write'], + 'namePrefix' => 'GoldenInput', + ]]; + + yield 'input_patch_merge' => ['input_patch_merge', GoldenInputResource::class, 'json', Schema::TYPE_INPUT, 'golden_input_patch', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:write'], + 'namePrefix' => 'GoldenInput', + ]]; + + yield 'input_put_nonstandard' => ['input_put_nonstandard', GoldenInputResource::class, 'json', Schema::TYPE_INPUT, 'golden_input_put_nonstandard', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:write'], + 'namePrefix' => 'GoldenInput', + ]]; + + yield 'input_validation_groups' => ['input_validation_groups', GoldenValidatedResource::class, 'json', Schema::TYPE_INPUT, 'golden_validated_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:write'], + 'validationGroups' => ['golden:create'], + 'namePrefix' => 'GoldenValidated', + ]]; + + yield 'output_nested_attributes' => ['output_nested_attributes', GoldenNestedResource::class, 'json', Schema::TYPE_OUTPUT, 'golden_nested_get_attributes', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'attributes' => ['name', 'author' => ['name']], + 'namePrefix' => 'GoldenNested', + ]]; + + yield 'output_attribute_selected_relation' => ['output_attribute_selected_relation', AttributeSelectedBook::class, 'json', Schema::TYPE_OUTPUT, 'attribute_selected_book_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'attributes' => ['title', 'author' => ['name']], + 'namePrefix' => 'AttributeSelectedBook', + ]]; + + yield 'input_attribute_selected_relation' => ['input_attribute_selected_relation', AttributeSelectedBook::class, 'json', Schema::TYPE_INPUT, 'attribute_selected_book_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'attributes' => ['title', 'author' => ['name']], + 'namePrefix' => 'AttributeSelectedBook', + ]]; + + yield 'output_ignored_attributes' => ['output_ignored_attributes', GoldenNestedResource::class, 'json', Schema::TYPE_OUTPUT, 'golden_nested_get_ignored', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'ignoredAttributes' => ['secret'], + 'namePrefix' => 'GoldenNested', + ]]; + + yield 'input_no_extra_attributes' => ['input_no_extra_attributes', GoldenStrictInputResource::class, 'json', Schema::TYPE_INPUT, 'golden_strict_input_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'allowExtraAttributes' => false, + 'namePrefix' => 'GoldenStrictInput', + ]]; + + yield 'output_definition_name' => ['output_definition_name', GoldenBook::class, 'json', Schema::TYPE_OUTPUT, 'golden_book_get', $openApiGroups + [SchemaFactory::OPENAPI_DEFINITION_NAME => 'Custom'], Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:read'], + 'definitionName' => 'Custom', + 'description' => 'Retrieves a golden book.', + 'deprecated' => true, + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'output_dto' => ['output_dto', GoldenOutputDtoResource::class, 'json', Schema::TYPE_OUTPUT, 'golden_output_dto_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'namePrefix' => 'GoldenOutputDtoResource', + ]]; + + yield 'output_relation_iri_vs_embed' => ['output_relation_iri_vs_embed', GoldenRelationResource::class, 'json', Schema::TYPE_OUTPUT, 'golden_relation_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:read', 'golden:embed'], + 'namePrefix' => 'GoldenRelation', + ]]; + + yield 'output_gen_id_false' => ['output_gen_id_false', GoldenRelationResource::class, 'json', Schema::TYPE_OUTPUT, 'golden_relation_get_noid', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:noid'], + 'namePrefix' => 'GoldenRelation', + ]]; + + yield 'non_resource_class' => ['non_resource_class', GoldenPlainObject::class, 'json', Schema::TYPE_OUTPUT, null, null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + ]]; + + yield 'input_query' => ['input_query', GoldenQueryResource::class, 'json', Schema::TYPE_INPUT, 'golden_query', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'namePrefix' => 'GoldenQuery', + ]]; + + yield 'dialect_openapi' => ['dialect_openapi', GoldenBook::class, 'json', Schema::TYPE_OUTPUT, 'golden_book_get', null, Schema::VERSION_OPENAPI, [ + 'dialect' => 'openApi31', + 'format' => 'json', + 'groups' => ['golden:read'], + 'description' => 'Retrieves a golden book.', + 'deprecated' => true, + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'dialect_swagger' => ['dialect_swagger', GoldenBook::class, 'json', Schema::TYPE_OUTPUT, 'golden_book_get', null, Schema::VERSION_SWAGGER, [ + 'dialect' => 'swagger20', + 'format' => 'json', + 'groups' => ['golden:read'], + 'description' => 'Retrieves a golden book.', + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'groups_normal' => ['groups_normal', GoldenGroupedItem::class, 'json', Schema::TYPE_OUTPUT, 'golden_grouped_item_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:read'], + 'namePrefix' => 'GoldenGroupedItem', + ]]; + + yield 'groups_explicit_context_split' => ['groups_explicit_context_split', GoldenGroupedItem::class, 'json', Schema::TYPE_OUTPUT, 'golden_grouped_item_get', ['foo' => 'bar'], Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'readGroups' => ['golden:read'], + 'writeGroups' => ['golden:write'], + 'namePrefix' => 'GoldenGroupedItem', + ]]; + + yield 'jsonld_output_item' => ['jsonld_output_item', GoldenBook::class, 'jsonld', Schema::TYPE_OUTPUT, 'golden_book_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'groups' => ['golden:read'], + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'jsonld_output_collection' => ['jsonld_output_collection', GoldenBook::class, 'jsonld', Schema::TYPE_OUTPUT, 'golden_book_get_collection', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'groups' => ['golden:read'], + 'collection' => true, + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'jsonld_input_post' => ['jsonld_input_post', GoldenInputResource::class, 'jsonld', Schema::TYPE_INPUT, 'golden_input_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'groups' => ['golden:write'], + 'namePrefix' => 'GoldenInput', + ]]; + + yield 'jsonld_relation_iri_vs_embed' => ['jsonld_relation_iri_vs_embed', GoldenRelationResource::class, 'jsonld', Schema::TYPE_OUTPUT, 'golden_relation_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'groups' => ['golden:read', 'golden:embed'], + 'namePrefix' => 'GoldenRelation', + ]]; + + yield 'jsonld_gen_id_false' => ['jsonld_gen_id_false', GoldenRelationResource::class, 'jsonld', Schema::TYPE_OUTPUT, 'golden_relation_get_noid', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'groups' => ['golden:noid'], + 'namePrefix' => 'GoldenRelation', + ]]; + + yield 'jsonhal_output_item' => ['jsonhal_output_item', GoldenBook::class, 'jsonhal', Schema::TYPE_OUTPUT, 'golden_book_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonhal', + 'groups' => ['golden:read'], + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'jsonhal_relation_iri_vs_embed' => ['jsonhal_relation_iri_vs_embed', GoldenRelationResource::class, 'jsonhal', Schema::TYPE_OUTPUT, 'golden_relation_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonhal', + 'groups' => ['golden:read', 'golden:embed'], + 'namePrefix' => 'GoldenRelation', + ]]; + + yield 'jsonapi_output_item' => ['jsonapi_output_item', GoldenBook::class, 'jsonapi', Schema::TYPE_OUTPUT, 'golden_book_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonapi', + 'groups' => ['golden:read'], + 'namePrefix' => 'GoldenBook', + ]]; + + yield 'jsonapi_relation_iri_vs_embed' => ['jsonapi_relation_iri_vs_embed', GoldenRelationResource::class, 'jsonapi', Schema::TYPE_OUTPUT, 'golden_relation_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonapi', + 'groups' => ['golden:read', 'golden:embed'], + 'namePrefix' => 'GoldenRelation', + ]]; + + yield 'jsonapi_input_post' => ['jsonapi_input_post', GoldenInputResource::class, 'jsonapi', Schema::TYPE_INPUT, 'golden_input_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonapi', + 'groups' => ['golden:write'], + 'namePrefix' => 'GoldenInput', + ]]; + + yield 'multi_resource_nested_output' => ['multi_resource_nested_output', GoldenMultiResourceOwner::class, 'json', Schema::TYPE_OUTPUT, 'golden_multi_resource_owner_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:read'], + 'namePrefix' => 'GoldenMultiResourceOwner', + ]]; + + yield 'multi_resource_nested_input' => ['multi_resource_nested_input', GoldenMultiResourceOwner::class, 'json', Schema::TYPE_INPUT, 'golden_multi_resource_owner_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:write'], + 'namePrefix' => 'GoldenMultiResourceOwner', + ]]; + + yield 'input_nested_partial_children' => ['input_nested_partial_children', GoldenPartialChildrenOwner::class, 'json', Schema::TYPE_INPUT, 'golden_partial_children_owner_post', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'groups' => ['golden:write'], + 'namePrefix' => 'GoldenPartialChildrenOwner', + ]]; + + yield 'output_builtin_type_quirks' => ['output_builtin_type_quirks', BuiltinTypeQuirks::class, 'json', Schema::TYPE_OUTPUT, 'builtin_type_quirks_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'namePrefix' => 'BuiltinTypeQuirks', + ]]; + + yield 'mcp_tool_output' => ['mcp_tool_output', McpFormatTool::class, 'json', Schema::TYPE_OUTPUT, 'format_message', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'schemaFactory' => 'mcp', + 'namePrefix' => 'McpFormatTool', + ]]; + } + + private static function normalize(mixed $value): mixed + { + $value = json_decode(json_encode($value, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR); + + return self::sortKeys($value); + } + + private static function sortKeys(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; + } + + $value = array_map(self::sortKeys(...), $value); + if (!array_is_list($value)) { + ksort($value); + } + + return $value; + } +} diff --git a/tests/Functional/JsonSchema/golden/dialect_openapi.json b/tests/Functional/JsonSchema/golden/dialect_openapi.json new file mode 100644 index 0000000000..1b716f2580 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/dialect_openapi.json @@ -0,0 +1,36 @@ +{ + "definitions": { + "GoldenBook-golden.read": { + "deprecated": true, + "description": "Retrieves a golden book.", + "externalDocs": { + "url": "https://schema.org/Book" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/components/schemas/GoldenBook-golden.read" + } +} diff --git a/tests/Functional/JsonSchema/golden/dialect_swagger.json b/tests/Functional/JsonSchema/golden/dialect_swagger.json new file mode 100644 index 0000000000..5936fd3678 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/dialect_swagger.json @@ -0,0 +1,35 @@ +{ + "definitions": { + "GoldenBook-golden.read": { + "description": "Retrieves a golden book.", + "externalDocs": { + "url": "https://schema.org/Book" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenBook-golden.read" + } +} diff --git a/tests/Functional/JsonSchema/golden/groups_explicit_context_split.json b/tests/Functional/JsonSchema/golden/groups_explicit_context_split.json new file mode 100644 index 0000000000..8711fcf657 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/groups_explicit_context_split.json @@ -0,0 +1,38 @@ +{ + "definitions": { + "GoldenGroupedItem": { + "properties": { + "createdAt": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "id": { + "readOnly": true, + "type": [ + "integer", + "null" + ] + }, + "secretToken": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenGroupedItem", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/groups_normal.json b/tests/Functional/JsonSchema/golden/groups_normal.json new file mode 100644 index 0000000000..afa4e4a3a3 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/groups_normal.json @@ -0,0 +1,29 @@ +{ + "definitions": { + "GoldenGroupedItem-golden.read": { + "properties": { + "createdAt": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenGroupedItem-golden.read", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_attribute_selected_relation.json b/tests/Functional/JsonSchema/golden/input_attribute_selected_relation.json new file mode 100644 index 0000000000..e3ef1fbafc --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_attribute_selected_relation.json @@ -0,0 +1,36 @@ +{ + "definitions": { + "AttributeSelectedAuthor-name": { + "properties": { + "name": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "AttributeSelectedBook-title_author.name": { + "properties": { + "author": { + "anyOf": [ + { + "$ref": "#/definitions/AttributeSelectedAuthor-name" + }, + { + "type": "null" + } + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/AttributeSelectedBook-title_author.name", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_nested_partial_children.json b/tests/Functional/JsonSchema/golden/input_nested_partial_children.json new file mode 100644 index 0000000000..80fd155c77 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_nested_partial_children.json @@ -0,0 +1,58 @@ +{ + "definitions": { + "GoldenNonStandardPutChild-golden.write.partial": { + "properties": { + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenPartialChildrenOwner-golden.write": { + "properties": { + "name": { + "default": "", + "type": "string" + }, + "patchChild": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenPatchOnlyChild-golden.write.jsonMergePatch" + }, + { + "type": "null" + } + ] + }, + "putChild": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenNonStandardPutChild-golden.write.partial" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "GoldenPatchOnlyChild-golden.write.jsonMergePatch": { + "properties": { + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenPartialChildrenOwner-golden.write", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_no_extra_attributes.json b/tests/Functional/JsonSchema/golden/input_no_extra_attributes.json new file mode 100644 index 0000000000..48f98c6aa7 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_no_extra_attributes.json @@ -0,0 +1,24 @@ +{ + "definitions": { + "GoldenStrictInput": { + "additionalProperties": false, + "properties": { + "name": { + "default": "", + "type": "string" + }, + "quantity": { + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenStrictInput", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_patch_merge.json b/tests/Functional/JsonSchema/golden/input_patch_merge.json new file mode 100644 index 0000000000..e3d4efb13e --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_patch_merge.json @@ -0,0 +1,31 @@ +{ + "definitions": { + "GoldenInput-golden.write.jsonMergePatch": { + "properties": { + "computedLabel": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "password": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenInput-golden.write.jsonMergePatch", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_post_groups.json b/tests/Functional/JsonSchema/golden/input_post_groups.json new file mode 100644 index 0000000000..47b51ccbda --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_post_groups.json @@ -0,0 +1,34 @@ +{ + "definitions": { + "GoldenInput-golden.write": { + "properties": { + "computedLabel": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "password": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "title": { + "default": "", + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenInput-golden.write", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_put_nonstandard.json b/tests/Functional/JsonSchema/golden/input_put_nonstandard.json new file mode 100644 index 0000000000..ce3de39477 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_put_nonstandard.json @@ -0,0 +1,31 @@ +{ + "definitions": { + "GoldenInput-golden.write.partial": { + "properties": { + "computedLabel": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "password": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenInput-golden.write.partial", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_query.json b/tests/Functional/JsonSchema/golden/input_query.json new file mode 100644 index 0000000000..3ed5837000 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_query.json @@ -0,0 +1,26 @@ +{ + "definitions": { + "GoldenQuery.GoldenQueryCriteria": { + "properties": { + "limit": { + "type": [ + "integer", + "null" + ] + }, + "term": { + "default": "", + "type": "string" + } + }, + "required": [ + "term" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenQuery.GoldenQueryCriteria", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/input_validation_groups.json b/tests/Functional/JsonSchema/golden/input_validation_groups.json new file mode 100644 index 0000000000..4c641a538a --- /dev/null +++ b/tests/Functional/JsonSchema/golden/input_validation_groups.json @@ -0,0 +1,25 @@ +{ + "definitions": { + "GoldenValidated-golden.write_validation.golden.create": { + "properties": { + "name": { + "default": "", + "maxLength": 20, + "type": "string" + }, + "reference": { + "default": "", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenValidated-golden.write_validation.golden.create", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonapi_input_post.json b/tests/Functional/JsonSchema/golden/jsonapi_input_post.json new file mode 100644 index 0000000000..47b51ccbda --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonapi_input_post.json @@ -0,0 +1,34 @@ +{ + "definitions": { + "GoldenInput-golden.write": { + "properties": { + "computedLabel": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "password": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "title": { + "default": "", + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenInput-golden.write", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonapi_output_item.json b/tests/Functional/JsonSchema/golden/jsonapi_output_item.json new file mode 100644 index 0000000000..fba80f0237 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonapi_output_item.json @@ -0,0 +1,88 @@ +{ + "definitions": { + "GoldenBook": { + "deprecated": true, + "description": "Retrieves a golden book.", + "externalDocs": { + "url": "https://schema.org/Book" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "internalNote": { + "default": "", + "type": "string" + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenBook.jsonapi": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "_id": { + "type": [ + "integer", + "null" + ] + }, + "internalNote": { + "default": "", + "type": "string" + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + } + } + }, + "schema": { + "$ref": "#/definitions/GoldenBook.jsonapi", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonapi_relation_iri_vs_embed.json b/tests/Functional/JsonSchema/golden/jsonapi_relation_iri_vs_embed.json new file mode 100644 index 0000000000..3604a511dc --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonapi_relation_iri_vs_embed.json @@ -0,0 +1,242 @@ +{ + "definitions": { + "GoldenRelated": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenRelated.jsonapi": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "_id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + } + }, + "GoldenRelated_noid": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenRelation": { + "properties": { + "embedded": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated" + }, + { + "type": "null" + } + ] + }, + "embeddedWithoutId": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated_noid" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "linked": { + "example": "https://example.com/", + "format": "iri-reference", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "GoldenRelation.jsonapi": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "_id": { + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "relationships": { + "properties": { + "embedded": { + "properties": { + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "id": { + "format": "iri-reference", + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + ] + } + } + }, + "embeddedWithoutId": { + "properties": { + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "id": { + "format": "iri-reference", + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + ] + } + } + }, + "linked": { + "properties": { + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "id": { + "format": "iri-reference", + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + ] + } + } + } + }, + "type": "object" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "included": { + "description": "Related resources requested via the \"include\" query parameter.", + "externalDocs": { + "url": "https://jsonapi.org/format/#fetching-includes" + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated.jsonapi" + } + ] + }, + "readOnly": true, + "type": "array" + } + } + } + }, + "schema": { + "$ref": "#/definitions/GoldenRelation.jsonapi", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonhal_output_item.json b/tests/Functional/JsonSchema/golden/jsonhal_output_item.json new file mode 100644 index 0000000000..01482eaa35 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonhal_output_item.json @@ -0,0 +1,64 @@ +{ + "definitions": { + "GoldenBook-golden.read": { + "deprecated": true, + "description": "Retrieves a golden book.", + "externalDocs": { + "url": "https://schema.org/Book" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenBook.jsonhal-golden.read": { + "allOf": [ + { + "properties": { + "_links": { + "properties": { + "self": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "$ref": "#/definitions/GoldenBook-golden.read" + } + ], + "description": "Retrieves a golden book." + } + }, + "schema": { + "$ref": "#/definitions/GoldenBook.jsonhal-golden.read", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonhal_relation_iri_vs_embed.json b/tests/Functional/JsonSchema/golden/jsonhal_relation_iri_vs_embed.json new file mode 100644 index 0000000000..861340a955 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonhal_relation_iri_vs_embed.json @@ -0,0 +1,78 @@ +{ + "definitions": { + "GoldenRelated-golden.read_golden.embed": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenRelation-golden.read_golden.embed": { + "properties": { + "embedded": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated-golden.read_golden.embed" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "linked": { + "example": "https://example.com/", + "format": "iri-reference", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "GoldenRelation.jsonhal-golden.read_golden.embed": { + "allOf": [ + { + "properties": { + "_links": { + "properties": { + "self": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "$ref": "#/definitions/GoldenRelation-golden.read_golden.embed" + } + ] + } + }, + "schema": { + "$ref": "#/definitions/GoldenRelation.jsonhal-golden.read_golden.embed", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonld_gen_id_false.json b/tests/Functional/JsonSchema/golden/jsonld_gen_id_false.json new file mode 100644 index 0000000000..f374820292 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonld_gen_id_false.json @@ -0,0 +1,132 @@ +{ + "definitions": { + "GoldenRelated.jsonld-golden.noid_noid": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchemaWithoutId" + }, + { + "properties": { + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + ] + }, + "GoldenRelation.jsonld-golden.noid": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "properties": { + "embeddedWithoutId": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated.jsonld-golden.noid_noid" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + ] + }, + "HydraItemBaseSchema": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + } + }, + "required": [ + "@id", + "@type" + ], + "type": "object" + }, + "HydraItemBaseSchemaWithoutId": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + } + }, + "required": [ + "@type" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenRelation.jsonld-golden.noid" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonld_input_post.json b/tests/Functional/JsonSchema/golden/jsonld_input_post.json new file mode 100644 index 0000000000..47b51ccbda --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonld_input_post.json @@ -0,0 +1,34 @@ +{ + "definitions": { + "GoldenInput-golden.write": { + "properties": { + "computedLabel": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "password": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "title": { + "default": "", + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenInput-golden.write", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonld_output_collection.json b/tests/Functional/JsonSchema/golden/jsonld_output_collection.json new file mode 100644 index 0000000000..bf0809e0db --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonld_output_collection.json @@ -0,0 +1,206 @@ +{ + "definitions": { + "GoldenBook.jsonld-golden.read": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + ] + }, + "HydraCollectionBaseSchema": { + "allOf": [ + { + "$ref": "#/definitions/HydraCollectionBaseSchemaNoPagination" + }, + { + "properties": { + "hydra:view": { + "example": { + "@id": "string", + "@type": "string", + "hydra:first": "string", + "hydra:last": "string", + "hydra:next": "string", + "hydra:previous": "string" + }, + "properties": { + "@id": { + "format": "iri-reference", + "type": "string" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "format": "iri-reference", + "type": [ + "string", + "null" + ] + }, + "hydra:last": { + "format": "iri-reference", + "type": [ + "string", + "null" + ] + }, + "hydra:next": { + "format": "iri-reference", + "type": [ + "string", + "null" + ] + }, + "hydra:previous": { + "format": "iri-reference", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "type": "object" + } + ] + }, + "HydraCollectionBaseSchemaNoPagination": { + "properties": { + "hydra:search": { + "properties": { + "@type": { + "type": "string" + }, + "hydra:mapping": { + "items": { + "properties": { + "@type": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + }, + "variable": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "hydra:template": { + "type": "string" + }, + "hydra:variableRepresentation": { + "type": "string" + } + }, + "type": "object" + }, + "hydra:totalItems": { + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "HydraItemBaseSchema": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + } + }, + "required": [ + "@id", + "@type" + ], + "type": "object" + } + }, + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "#/definitions/HydraCollectionBaseSchema" + }, + { + "properties": { + "hydra:member": { + "items": { + "$ref": "#/definitions/GoldenBook.jsonld-golden.read" + }, + "type": "array" + } + }, + "required": [ + "hydra:member" + ], + "type": "object" + } + ], + "description": "GoldenBook.jsonld-golden.read collection.", + "type": "object" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonld_output_item.json b/tests/Functional/JsonSchema/golden/jsonld_output_item.json new file mode 100644 index 0000000000..494ef92736 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonld_output_item.json @@ -0,0 +1,84 @@ +{ + "definitions": { + "GoldenBook.jsonld-golden.read": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "deprecated": true, + "externalDocs": { + "url": "https://schema.org/Book" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "Retrieves a golden book." + }, + "HydraItemBaseSchema": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + } + }, + "required": [ + "@id", + "@type" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenBook.jsonld-golden.read" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonld_relation_iri_vs_embed.json b/tests/Functional/JsonSchema/golden/jsonld_relation_iri_vs_embed.json new file mode 100644 index 0000000000..f2dbf34528 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonld_relation_iri_vs_embed.json @@ -0,0 +1,106 @@ +{ + "definitions": { + "GoldenRelated.jsonld-golden.read_golden.embed": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + ] + }, + "GoldenRelation.jsonld-golden.read_golden.embed": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "properties": { + "embedded": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated.jsonld-golden.read_golden.embed" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "linked": { + "example": "https://example.com/", + "format": "iri-reference", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + ] + }, + "HydraItemBaseSchema": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + } + }, + "required": [ + "@id", + "@type" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenRelation.jsonld-golden.read_golden.embed" + } +} diff --git a/tests/Functional/JsonSchema/golden/mcp_tool_output.json b/tests/Functional/JsonSchema/golden/mcp_tool_output.json new file mode 100644 index 0000000000..f05ae5b0ad --- /dev/null +++ b/tests/Functional/JsonSchema/golden/mcp_tool_output.json @@ -0,0 +1,12 @@ +{ + "definitions": [], + "schema": { + "description": "Echo a message, serialized with the format declared on the operation", + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } +} diff --git a/tests/Functional/JsonSchema/golden/multi_resource_nested_input.json b/tests/Functional/JsonSchema/golden/multi_resource_nested_input.json new file mode 100644 index 0000000000..d1c3e8490e --- /dev/null +++ b/tests/Functional/JsonSchema/golden/multi_resource_nested_input.json @@ -0,0 +1,32 @@ +{ + "definitions": { + "GoldenMultiResource-golden.write": { + "properties": { + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenMultiResourceOwner-golden.write": { + "properties": { + "entity": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenMultiResource-golden.write" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenMultiResourceOwner-golden.write", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/multi_resource_nested_output.json b/tests/Functional/JsonSchema/golden/multi_resource_nested_output.json new file mode 100644 index 0000000000..9a1abe5a3c --- /dev/null +++ b/tests/Functional/JsonSchema/golden/multi_resource_nested_output.json @@ -0,0 +1,44 @@ +{ + "definitions": { + "GoldenAdminMultiResource-golden.read": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenMultiResourceOwner-golden.read": { + "properties": { + "entity": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenAdminMultiResource-golden.read" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenMultiResourceOwner-golden.read", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/non_resource_class.json b/tests/Functional/JsonSchema/golden/non_resource_class.json new file mode 100644 index 0000000000..9c1d34c4ee --- /dev/null +++ b/tests/Functional/JsonSchema/golden/non_resource_class.json @@ -0,0 +1,27 @@ +{ + "definitions": { + "GoldenPlainObject": { + "properties": { + "count": { + "type": [ + "integer", + "null" + ] + }, + "flag": { + "default": false, + "type": "boolean" + }, + "name": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenPlainObject", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_attribute_selected_relation.json b/tests/Functional/JsonSchema/golden/output_attribute_selected_relation.json new file mode 100644 index 0000000000..e3ef1fbafc --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_attribute_selected_relation.json @@ -0,0 +1,36 @@ +{ + "definitions": { + "AttributeSelectedAuthor-name": { + "properties": { + "name": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "AttributeSelectedBook-title_author.name": { + "properties": { + "author": { + "anyOf": [ + { + "$ref": "#/definitions/AttributeSelectedAuthor-name" + }, + { + "type": "null" + } + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/AttributeSelectedBook-title_author.name", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json b/tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json new file mode 100644 index 0000000000..7cf3e2f5a0 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json @@ -0,0 +1,41 @@ +{ + "definitions": { + "BuiltinTypeQuirks": { + "properties": { + "amount": { + "format": "string", + "type": [ + "string", + "null" + ] + }, + "anything": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "untyped": { + "items": { + "type": [ + "string", + "null" + ] + }, + "type": "array" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/BuiltinTypeQuirks", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_collection.json b/tests/Functional/JsonSchema/golden/output_collection.json new file mode 100644 index 0000000000..46403d1763 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_collection.json @@ -0,0 +1,35 @@ +{ + "definitions": { + "GoldenBook-golden.read": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "items": { + "$ref": "#/definitions/GoldenBook-golden.read" + }, + "type": "array" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_definition_name.json b/tests/Functional/JsonSchema/golden/output_definition_name.json new file mode 100644 index 0000000000..256661fe61 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_definition_name.json @@ -0,0 +1,37 @@ +{ + "definitions": { + "GoldenBook-Custom": { + "deprecated": true, + "description": "Retrieves a golden book.", + "externalDocs": { + "url": "https://schema.org/Book" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenBook-Custom", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_dto.json b/tests/Functional/JsonSchema/golden/output_dto.json new file mode 100644 index 0000000000..b8eb10881c --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_dto.json @@ -0,0 +1,21 @@ +{ + "definitions": { + "GoldenOutputDtoResource.GoldenOutputDto": { + "properties": { + "label": { + "default": "", + "type": "string" + }, + "total": { + "default": 0, + "type": "integer" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenOutputDtoResource.GoldenOutputDto", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_gen_id_false.json b/tests/Functional/JsonSchema/golden/output_gen_id_false.json new file mode 100644 index 0000000000..b3ecde2e6e --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_gen_id_false.json @@ -0,0 +1,38 @@ +{ + "definitions": { + "GoldenRelated-golden.noid_noid": { + "properties": { + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenRelation-golden.noid": { + "properties": { + "embeddedWithoutId": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated-golden.noid_noid" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenRelation-golden.noid", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_ignored_attributes.json b/tests/Functional/JsonSchema/golden/output_ignored_attributes.json new file mode 100644 index 0000000000..0eb7e0dc47 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_ignored_attributes.json @@ -0,0 +1,46 @@ +{ + "definitions": { + "GoldenAuthor": { + "properties": { + "email": { + "default": "", + "type": "string" + }, + "name": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenNested": { + "properties": { + "author": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenAuthor" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "name": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenNested", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_item.json b/tests/Functional/JsonSchema/golden/output_item.json new file mode 100644 index 0000000000..ee3554c92a --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_item.json @@ -0,0 +1,37 @@ +{ + "definitions": { + "GoldenBook-golden.read": { + "deprecated": true, + "description": "Retrieves a golden book.", + "externalDocs": { + "url": "https://schema.org/Book" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenBook-golden.read", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_nested_attributes.json b/tests/Functional/JsonSchema/golden/output_nested_attributes.json new file mode 100644 index 0000000000..778b46f032 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_nested_attributes.json @@ -0,0 +1,36 @@ +{ + "definitions": { + "GoldenAuthor-name": { + "properties": { + "name": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenNested-name_author.name": { + "properties": { + "author": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenAuthor-name" + }, + { + "type": "null" + } + ] + }, + "name": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenNested-name_author.name", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/output_relation_iri_vs_embed.json b/tests/Functional/JsonSchema/golden/output_relation_iri_vs_embed.json new file mode 100644 index 0000000000..2fca9b3fc4 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_relation_iri_vs_embed.json @@ -0,0 +1,52 @@ +{ + "definitions": { + "GoldenRelated-golden.read_golden.embed": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenRelation-golden.read_golden.embed": { + "properties": { + "embedded": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenRelated-golden.read_golden.embed" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + }, + "linked": { + "example": "https://example.com/", + "format": "iri-reference", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenRelation-golden.read_golden.embed", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} From 6027bd89a10417a72d5ccc49634bd42bce9e1918 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 6 Oct 2026 23:19:07 +0200 Subject: [PATCH 02/13] feat(jsonschema): JSON-LD through the generator Nested resources get the Hydra item wrapper and their operation description from definition processors, the Hydra decorator keeps the root envelope. jsonld_nested_output_dto stays skipped until the 5.0 nested output DTO fix is merged up. --- .../JsonSchema/ItemDefinitionProcessor.php | 46 ++++++++ src/Hydra/JsonSchema/SchemaFactory.php | 22 +++- .../Generator/ApiPlatformDefinitionPolicy.php | 4 +- .../Generator/GeneratorSchemaFactory.php | 7 +- .../NestedOperationDefinitionProcessor.php | 43 ++++++++ .../JsonSchemaGolden/GoldenDescribedOwner.php | 42 ++++++++ .../GoldenDescribedRelated.php | 43 ++++++++ .../JsonSchemaGolden/GoldenOutputDtoOwner.php | 42 ++++++++ .../JsonSchema/GoldenSchemaTest.php | 42 +++++++- .../jsonld_nested_described_resource.json | 102 ++++++++++++++++++ .../golden/jsonld_nested_output_dto.json | 96 +++++++++++++++++ .../golden/nested_described_resource.json | 48 +++++++++ .../JsonSchema/golden/nested_output_dto.json | 44 ++++++++ 13 files changed, 572 insertions(+), 9 deletions(-) create mode 100644 src/Hydra/JsonSchema/ItemDefinitionProcessor.php create mode 100644 src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedOwner.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedRelated.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoOwner.php create mode 100644 tests/Functional/JsonSchema/golden/jsonld_nested_described_resource.json create mode 100644 tests/Functional/JsonSchema/golden/jsonld_nested_output_dto.json create mode 100644 tests/Functional/JsonSchema/golden/nested_described_resource.json create mode 100644 tests/Functional/JsonSchema/golden/nested_output_dto.json diff --git a/src/Hydra/JsonSchema/ItemDefinitionProcessor.php b/src/Hydra/JsonSchema/ItemDefinitionProcessor.php new file mode 100644 index 0000000000..560dd6a376 --- /dev/null +++ b/src/Hydra/JsonSchema/ItemDefinitionProcessor.php @@ -0,0 +1,46 @@ + + * + * 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\Hydra\JsonSchema; + +use ApiPlatform\JsonSchema\Generator\ApiPlatformDefinitionPolicy; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; + +final class ItemDefinitionProcessor implements DefinitionProcessorInterface +{ + public function __construct( + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly ApiPlatformDefinitionPolicy $definitionPolicy, + ) { + } + + public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + { + if (null === $parent || 'jsonld' !== $config->format || !$this->resourceClassResolver->isResourceClass($class)) { + return $definition; + } + + $baseName = $this->definitionPolicy->resolveGenId($parent) ? SchemaFactory::ITEM_BASE_SCHEMA_NAME : SchemaFactory::ITEM_WITHOUT_ID_BASE_SCHEMA_NAME; + $item = ['allOf' => [['$ref' => $config->dialect->refPath.$baseName], $definition]]; + + if (isset($definition['description'])) { + $item['description'] = $definition['description']; + unset($item['allOf'][1]['description']); + } + + return $item; + } +} diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index aa715cc5ac..0f83e80726 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -36,8 +36,8 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI use ResourceMetadataTrait; use SchemaUriPrefixTrait; - private const ITEM_BASE_SCHEMA_NAME = 'HydraItemBaseSchema'; - private const ITEM_WITHOUT_ID_BASE_SCHEMA_NAME = 'HydraItemBaseSchemaWithoutId'; + public const ITEM_BASE_SCHEMA_NAME = 'HydraItemBaseSchema'; + public const ITEM_WITHOUT_ID_BASE_SCHEMA_NAME = 'HydraItemBaseSchemaWithoutId'; private const COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION = 'HydraCollectionBaseSchemaNoPagination'; private const COLLECTION_BASE_SCHEMA_NAME = 'HydraCollectionBaseSchema'; @@ -130,6 +130,7 @@ public function buildSchema(string $className, string $format = 'jsonld', string $definitions = $schema->getDefinitions(); $prefix = $this->getSchemaUriPrefix($schema->getVersion()); $collectionKey = $schema->getItemsDefinitionKey(); + $this->addReferencedItemBaseSchemas($definitions, $prefix); if (!$collectionKey) { $definitionName = $schema->getRootDefinitionKey() ?? $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext); @@ -232,9 +233,8 @@ public function buildSchema(string $className, string $format = 'jsonld', string ]; } - $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext + ['schema_type' => $type]); $schema['type'] = 'object'; - $schema['description'] = "$definitionName collection."; + $schema['description'] = "$collectionKey collection."; $schema['allOf'] = [ ['$ref' => $prefix.(false === $operation->getPaginationEnabled() ? self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION : self::COLLECTION_BASE_SCHEMA_NAME)], [ @@ -267,6 +267,20 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void } } + private function addReferencedItemBaseSchemas(\ArrayObject $definitions, string $prefix): void + { + $references = []; + foreach ($definitions as $definition) { + $references[$definition['allOf'][0]['$ref'] ?? ''] = true; + } + + foreach ([self::ITEM_BASE_SCHEMA_NAME => self::ITEM_BASE_SCHEMA_WITH_ID, self::ITEM_WITHOUT_ID_BASE_SCHEMA_NAME => self::ITEM_BASE_SCHEMA_WITHOUT_ID] as $name => $baseSchema) { + if (isset($references[$prefix.$name]) && !isset($definitions[$name])) { + $definitions[$name] = $baseSchema; + } + } + } + private function decorateItemDefinition(string $definitionName, \ArrayObject $definitions, string $prefix, string $type, ?array $serializerContext): void { if (!isset($definitions[$definitionName])) { diff --git a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php index 703bda2a7c..5a11d5812e 100644 --- a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php +++ b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php @@ -149,7 +149,7 @@ public function nameFor(string $class, Configuration $config, ?DefinitionParent return preg_replace('/[^a-zA-Z0-9.\-_]/', self::GLUE, $name); } - private function resolveGenId(?DefinitionParent $parent): bool + public function resolveGenId(?DefinitionParent $parent): bool { for (; null !== $parent; $parent = $parent->parent) { try { @@ -166,7 +166,7 @@ private function resolveGenId(?DefinitionParent $parent): bool return true; } - private function findNestedOperation(string $class): ?Operation + public function findNestedOperation(string $class): ?Operation { if (!$this->isResourceClass($class)) { return null; diff --git a/src/JsonSchema/Generator/GeneratorSchemaFactory.php b/src/JsonSchema/Generator/GeneratorSchemaFactory.php index 3a3f18ed52..d04c4caf58 100644 --- a/src/JsonSchema/Generator/GeneratorSchemaFactory.php +++ b/src/JsonSchema/Generator/GeneratorSchemaFactory.php @@ -21,11 +21,15 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\JsonSchema\ClassSchemaResolver\NativeClassSchemaResolver; use Symfony\Component\JsonSchema\ClassSchemaResolver\UidClassSchemaResolver; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; use Symfony\Component\JsonSchema\SchemaGenerator; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; final class GeneratorSchemaFactory implements SchemaFactoryInterface { + /** + * @param iterable $definitionProcessors + */ public function __construct( private readonly ConfigurationFactory $configurationFactory, private readonly ApiPlatformDefinitionPolicy $definitionPolicy, @@ -33,6 +37,7 @@ public function __construct( private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly ?NameConverterInterface $nameConverter = null, + private readonly iterable $definitionProcessors = [], ) { } @@ -52,7 +57,7 @@ public function buildSchema(string $className, string $format = 'json', string $ [new ResourceIriClassSchemaResolver($accessor, $this->resourceClassResolver, $type), new NativeClassSchemaResolver(), new UidClassSchemaResolver()], [new ApiPropertySchemaProvider($accessor, $request->version)], [new ApiPropertySchemaEnricher($accessor, $this->resourceClassResolver, $request->version)], - [new BuiltinTypeDefinitionProcessor($accessor, $this->nameConverter), new PartialUpdateDefinitionProcessor($policy)], + [new BuiltinTypeDefinitionProcessor($accessor, $this->nameConverter), new PartialUpdateDefinitionProcessor($policy), new NestedOperationDefinitionProcessor($policy), ...$this->definitionProcessors], $this->nameConverter, ); diff --git a/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php b/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php new file mode 100644 index 0000000000..56a8de9024 --- /dev/null +++ b/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php @@ -0,0 +1,43 @@ + + * + * 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\Generator; + +use ApiPlatform\Metadata\HttpOperation; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; + +final class NestedOperationDefinitionProcessor implements DefinitionProcessorInterface +{ + public function __construct(private readonly ApiPlatformDefinitionPolicy $definitionPolicy) + { + } + + public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + { + if (null === $parent || null === $operation = $this->definitionPolicy->findNestedOperation($class)) { + return $definition; + } + + if ($description = $operation->getDescription()) { + $definition['description'] = $description; + } + + if ($operation instanceof HttpOperation && ($type = $operation->getTypes()[0] ?? null)) { + $definition['externalDocs'] = ['url' => $type]; + } + + return $definition; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedOwner.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedOwner.php new file mode 100644 index 0000000000..1385fc2c68 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedOwner.php @@ -0,0 +1,42 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'GoldenDescribedOwner', + operations: [ + new Get( + uriTemplate: '/golden_described_owners/{id}', + name: 'golden_described_owner_get', + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenDescribedOwner +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[ApiProperty(readableLink: true)] + public ?GoldenDescribedRelated $related = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedRelated.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedRelated.php new file mode 100644 index 0000000000..42f1e58fcd --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenDescribedRelated.php @@ -0,0 +1,43 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'GoldenDescribedAlias', + description: 'A described related resource.', + types: ['https://schema.org/Thing'], + operations: [ + new Get( + uriTemplate: '/golden_described_related/{id}', + name: 'golden_described_related_get', + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenDescribedRelated +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $label = ''; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoOwner.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoOwner.php new file mode 100644 index 0000000000..d88f3f9434 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenOutputDtoOwner.php @@ -0,0 +1,42 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'GoldenOutputDtoOwner', + operations: [ + new Get( + uriTemplate: '/golden_output_dto_owners/{id}', + name: 'golden_output_dto_owner_get', + provider: [self::class, 'provide'], + ), + ], +)] +final class GoldenOutputDtoOwner +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[ApiProperty(readableLink: true)] + public ?GoldenOutputDtoResource $dto = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Functional/JsonSchema/GoldenSchemaTest.php b/tests/Functional/JsonSchema/GoldenSchemaTest.php index b60df3ad7e..323346e33d 100644 --- a/tests/Functional/JsonSchema/GoldenSchemaTest.php +++ b/tests/Functional/JsonSchema/GoldenSchemaTest.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests\Functional\JsonSchema; +use ApiPlatform\Hydra\JsonSchema\ItemDefinitionProcessor; +use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; use ApiPlatform\JsonSchema\Generator\ApiPlatformDefinitionPolicy; use ApiPlatform\JsonSchema\Generator\ConfigurationFactory; use ApiPlatform\JsonSchema\Generator\GeneratorSchemaFactory; @@ -25,12 +27,15 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\AttributeSelectedBook; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\BuiltinTypeQuirks; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenBook; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenDescribedOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenDescribedRelated; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenGroupedItem; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenInputResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenMultiResourceEntity; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenMultiResourceOwner; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenNestedResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenNonStandardPutChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenOutputDtoOwner; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenOutputDtoResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenPartialChildrenOwner; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenPatchOnlyChild; @@ -70,12 +75,15 @@ public static function getResources(): array AttributeSelectedBook::class, BuiltinTypeQuirks::class, GoldenBook::class, + GoldenDescribedOwner::class, + GoldenDescribedRelated::class, GoldenGroupedItem::class, GoldenInputResource::class, GoldenMultiResourceEntity::class, GoldenMultiResourceOwner::class, GoldenNestedResource::class, GoldenNonStandardPutChild::class, + GoldenOutputDtoOwner::class, GoldenOutputDtoResource::class, GoldenPartialChildrenOwner::class, GoldenPatchOnlyChild::class, @@ -131,14 +139,18 @@ public function testBuildSchemaMatchesGolden(string $case, string $className, st #[DataProvider('provideCases')] public function testGenerateMatchesGolden(string $case, string $className, string $format, string $type, ?string $operationName, ?array $serializerContext, string $version, array $configuration): void { - if ('json' !== $format) { - $this->markTestSkipped('Stage 1 covers the json format only.'); + if (!\in_array($format, ['json', 'jsonld'], true)) { + $this->markTestSkipped('Stage 2 covers the json and jsonld formats only.'); } if ('mcp' === ($configuration['schemaFactory'] ?? null)) { $this->markTestSkipped('The MCP schema factory is not ported in stage 1.'); } + if ('jsonld_nested_output_dto' === $case) { + $this->markTestSkipped('The Hydra decorator re-resolves the nested operation by format, so jsonld describes the output DTO of an embedded resource while json does not: pending decision.'); + } + if ('groups_explicit_context_split' === $case) { $this->markTestSkipped('Pins the explicit-context groups bug fixed in api-platform/core#8643.'); } @@ -157,7 +169,9 @@ public function testGenerateMatchesGolden(string $case, string $className, strin $propertyMetadataFactory, $resourceClassResolver, $container->has('api_platform.name_converter') ? $container->get('api_platform.name_converter') : null, + [new ItemDefinitionProcessor($resourceClassResolver, $policy)], ); + $factory = new HydraSchemaFactory($factory, $container->getParameter('api_platform.serializer.default_context'), $container->get('api_platform.json_schema.definition_name_factory'), $metadataFactory); $schema = $factory->buildSchema($className, $format, $type, $operation, new Schema($version), $serializerContext); @@ -428,6 +442,30 @@ public static function provideCases(): iterable 'namePrefix' => 'BuiltinTypeQuirks', ]]; + yield 'nested_described_resource' => ['nested_described_resource', GoldenDescribedOwner::class, 'json', Schema::TYPE_OUTPUT, 'golden_described_owner_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'namePrefix' => 'GoldenDescribedOwner', + ]]; + + yield 'nested_output_dto' => ['nested_output_dto', GoldenOutputDtoOwner::class, 'json', Schema::TYPE_OUTPUT, 'golden_output_dto_owner_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'namePrefix' => 'GoldenOutputDtoOwner', + ]]; + + yield 'jsonld_nested_described_resource' => ['jsonld_nested_described_resource', GoldenDescribedOwner::class, 'jsonld', Schema::TYPE_OUTPUT, 'golden_described_owner_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'namePrefix' => 'GoldenDescribedOwner', + ]]; + + yield 'jsonld_nested_output_dto' => ['jsonld_nested_output_dto', GoldenOutputDtoOwner::class, 'jsonld', Schema::TYPE_OUTPUT, 'golden_output_dto_owner_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'namePrefix' => 'GoldenOutputDtoOwner', + ]]; + yield 'mcp_tool_output' => ['mcp_tool_output', McpFormatTool::class, 'json', Schema::TYPE_OUTPUT, 'format_message', null, Schema::VERSION_JSON_SCHEMA, [ 'dialect' => 'jsonSchema202012', 'format' => 'json', diff --git a/tests/Functional/JsonSchema/golden/jsonld_nested_described_resource.json b/tests/Functional/JsonSchema/golden/jsonld_nested_described_resource.json new file mode 100644 index 0000000000..d20fb6eeae --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonld_nested_described_resource.json @@ -0,0 +1,102 @@ +{ + "definitions": { + "GoldenDescribedAlias.jsonld": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "externalDocs": { + "url": "https://schema.org/Thing" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + ], + "description": "A described related resource." + }, + "GoldenDescribedOwner.jsonld": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "related": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenDescribedAlias.jsonld" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + ] + }, + "HydraItemBaseSchema": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + } + }, + "required": [ + "@id", + "@type" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenDescribedOwner.jsonld" + } +} diff --git a/tests/Functional/JsonSchema/golden/jsonld_nested_output_dto.json b/tests/Functional/JsonSchema/golden/jsonld_nested_output_dto.json new file mode 100644 index 0000000000..bc176e9271 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonld_nested_output_dto.json @@ -0,0 +1,96 @@ +{ + "definitions": { + "GoldenOutputDtoOwner.jsonld": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "properties": { + "dto": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenOutputDtoResource.GoldenOutputDto.jsonld" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + ] + }, + "GoldenOutputDtoResource.GoldenOutputDto.jsonld": { + "allOf": [ + { + "$ref": "#/definitions/HydraItemBaseSchema" + }, + { + "properties": { + "label": { + "default": "", + "type": "string" + }, + "total": { + "default": 0, + "type": "integer" + } + }, + "type": "object" + } + ] + }, + "HydraItemBaseSchema": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + } + }, + "required": [ + "@id", + "@type" + ], + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenOutputDtoOwner.jsonld" + } +} diff --git a/tests/Functional/JsonSchema/golden/nested_described_resource.json b/tests/Functional/JsonSchema/golden/nested_described_resource.json new file mode 100644 index 0000000000..ddfde69944 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/nested_described_resource.json @@ -0,0 +1,48 @@ +{ + "definitions": { + "GoldenDescribedAlias": { + "description": "A described related resource.", + "externalDocs": { + "url": "https://schema.org/Thing" + }, + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "label": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenDescribedOwner": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "related": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenDescribedAlias" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenDescribedOwner", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Functional/JsonSchema/golden/nested_output_dto.json b/tests/Functional/JsonSchema/golden/nested_output_dto.json new file mode 100644 index 0000000000..a7db24e910 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/nested_output_dto.json @@ -0,0 +1,44 @@ +{ + "definitions": { + "GoldenOutputDtoOwner": { + "properties": { + "dto": { + "anyOf": [ + { + "$ref": "#/definitions/GoldenOutputDtoResource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "GoldenOutputDtoResource": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "internal": { + "default": "", + "type": "string" + } + }, + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenOutputDtoOwner", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} From ba2a3b1bbec88e7d550ccd55bf33128f66ef61f6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 7 Oct 2026 07:41:26 +0200 Subject: [PATCH 03/13] feat(jsonschema): HAL, JSON:API and MCP through the generator --- src/JsonApi/JsonSchema/SchemaFactory.php | 2 +- .../Generator/ApiPropertySchemaEnricher.php | 2 +- .../JsonSchema/GoldenSchemaTest.php | 71 +++++-- .../golden/jsonhal_output_collection.json | 174 ++++++++++++++++ .../golden/mcp_loader_collection_output.json | 186 ++++++++++++++++++ .../JsonSchema/golden/mcp_loader_input.json | 11 ++ .../golden/mcp_loader_item_output.json | 63 ++++++ .../golden/mcp_loader_output_dto.json | 17 ++ 8 files changed, 513 insertions(+), 13 deletions(-) create mode 100644 tests/Functional/JsonSchema/golden/jsonhal_output_collection.json create mode 100644 tests/Functional/JsonSchema/golden/mcp_loader_collection_output.json create mode 100644 tests/Functional/JsonSchema/golden/mcp_loader_input.json create mode 100644 tests/Functional/JsonSchema/golden/mcp_loader_item_output.json create mode 100644 tests/Functional/JsonSchema/golden/mcp_loader_output_dto.json diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index 87cb12d896..d4b0ab154f 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -123,7 +123,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin // That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes $jsonApiSerializerContext = $serializerContext; if (true === ($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) && $inputOrOutputClass === $className) { - unset($jsonApiSerializerContext['groups']); + $jsonApiSerializerContext['groups'] = null; } $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); diff --git a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php index 13b4d66bd6..2d2e1027a2 100644 --- a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php +++ b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php @@ -45,7 +45,7 @@ public function enrich(PropertySchema $property, Configuration $config): Propert } $contextSchema = Schema::VERSION_JSON_SCHEMA === $this->version ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); - $schema = [...$property->schema, ...array_diff_key($metadata->getSchema() ?? [], array_flip(self::OWNED_KEYWORDS)), ...$this->getDocumentation($metadata)]; + $schema = [...array_diff_key($property->schema, ['default' => true]), ...array_diff_key($metadata->getSchema() ?? [], array_flip(self::OWNED_KEYWORDS)), ...$this->getDocumentation($metadata)]; if ($contextSchema) { if (array_intersect_key($contextSchema, array_flip(self::STRUCTURAL_KEYWORDS))) { diff --git a/tests/Functional/JsonSchema/GoldenSchemaTest.php b/tests/Functional/JsonSchema/GoldenSchemaTest.php index 323346e33d..dc497730f6 100644 --- a/tests/Functional/JsonSchema/GoldenSchemaTest.php +++ b/tests/Functional/JsonSchema/GoldenSchemaTest.php @@ -13,14 +13,17 @@ namespace ApiPlatform\Tests\Functional\JsonSchema; +use ApiPlatform\Hal\JsonSchema\SchemaFactory as HalSchemaFactory; use ApiPlatform\Hydra\JsonSchema\ItemDefinitionProcessor; use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; +use ApiPlatform\JsonApi\JsonSchema\SchemaFactory as JsonApiSchemaFactory; use ApiPlatform\JsonSchema\Generator\ApiPlatformDefinitionPolicy; use ApiPlatform\JsonSchema\Generator\ConfigurationFactory; use ApiPlatform\JsonSchema\Generator\GeneratorSchemaFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\JsonSchema\SchemaFactory as McpSchemaFactory; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\AttributeSelectedAuthor; @@ -46,6 +49,9 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenStrictInputResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenValidatedResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpFormatTool; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\McpBookOutputDto; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\SearchDto; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\McpBook; use ApiPlatform\Tests\SetupClassResourcesTrait; use PHPUnit\Framework\Attributes\DataProvider; @@ -92,6 +98,7 @@ public static function getResources(): array GoldenRelationResource::class, GoldenStrictInputResource::class, GoldenValidatedResource::class, + McpBook::class, McpFormatTool::class, ]; } @@ -107,7 +114,7 @@ public function testBuildSchemaMatchesGolden(string $case, string $className, st $operation = null; if ('mcp' === ($configuration['schemaFactory'] ?? null)) { - $operation = $this->resourceMetadataCollectionFactory->create($className)[0]->getMcp()[$operationName]; + $operation = $this->resourceMetadataCollectionFactory->create($configuration['mcpResource'] ?? $className)[0]->getMcp()[$operationName]; } elseif (null !== $operationName) { $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation($operationName); } @@ -139,14 +146,6 @@ public function testBuildSchemaMatchesGolden(string $case, string $className, st #[DataProvider('provideCases')] public function testGenerateMatchesGolden(string $case, string $className, string $format, string $type, ?string $operationName, ?array $serializerContext, string $version, array $configuration): void { - if (!\in_array($format, ['json', 'jsonld'], true)) { - $this->markTestSkipped('Stage 2 covers the json and jsonld formats only.'); - } - - if ('mcp' === ($configuration['schemaFactory'] ?? null)) { - $this->markTestSkipped('The MCP schema factory is not ported in stage 1.'); - } - if ('jsonld_nested_output_dto' === $case) { $this->markTestSkipped('The Hydra decorator re-resolves the nested operation by format, so jsonld describes the output DTO of an embedded resource while json does not: pending decision.'); } @@ -155,7 +154,13 @@ public function testGenerateMatchesGolden(string $case, string $className, strin $this->markTestSkipped('Pins the explicit-context groups bug fixed in api-platform/core#8643.'); } - $operation = null !== $operationName ? $this->resourceMetadataCollectionFactory->create($className)->getOperation($operationName) : null; + $isMcp = 'mcp' === ($configuration['schemaFactory'] ?? null); + $operation = null; + if ($isMcp) { + $operation = $this->resourceMetadataCollectionFactory->create($configuration['mcpResource'] ?? $className)[0]->getMcp()[$operationName]; + } elseif (null !== $operationName) { + $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation($operationName); + } $container = self::getContainer(); $metadataFactory = $container->get('api_platform.metadata.resource.metadata_collection_factory'); @@ -171,7 +176,13 @@ public function testGenerateMatchesGolden(string $case, string $className, strin $container->has('api_platform.name_converter') ? $container->get('api_platform.name_converter') : null, [new ItemDefinitionProcessor($resourceClassResolver, $policy)], ); - $factory = new HydraSchemaFactory($factory, $container->getParameter('api_platform.serializer.default_context'), $container->get('api_platform.json_schema.definition_name_factory'), $metadataFactory); + $definitionNameFactory = $container->get('api_platform.json_schema.definition_name_factory'); + $factory = new JsonApiSchemaFactory($factory, $propertyMetadataFactory, $resourceClassResolver, $metadataFactory, $definitionNameFactory, $container->get('api_platform.jsonapi.resource_linkage_resolver')); + $factory = new HydraSchemaFactory($factory, $container->getParameter('api_platform.serializer.default_context'), $definitionNameFactory, $metadataFactory); + $factory = new HalSchemaFactory($factory, $definitionNameFactory, $metadataFactory); + if ($isMcp) { + $factory = new McpSchemaFactory($factory); + } $schema = $factory->buildSchema($className, $format, $type, $operation, new Schema($version), $serializerContext); @@ -394,6 +405,14 @@ public static function provideCases(): iterable 'namePrefix' => 'GoldenRelation', ]]; + yield 'jsonhal_output_collection' => ['jsonhal_output_collection', GoldenBook::class, 'jsonhal', Schema::TYPE_OUTPUT, 'golden_book_get_collection', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonhal', + 'groups' => ['golden:read'], + 'collection' => true, + 'namePrefix' => 'GoldenBook', + ]]; + yield 'jsonapi_output_item' => ['jsonapi_output_item', GoldenBook::class, 'jsonapi', Schema::TYPE_OUTPUT, 'golden_book_get', null, Schema::VERSION_JSON_SCHEMA, [ 'dialect' => 'jsonSchema202012', 'format' => 'jsonapi', @@ -472,6 +491,36 @@ public static function provideCases(): iterable 'schemaFactory' => 'mcp', 'namePrefix' => 'McpFormatTool', ]]; + + yield 'mcp_loader_item_output' => ['mcp_loader_item_output', McpBook::class, 'jsonld', Schema::TYPE_OUTPUT, 'get_book_info', [SchemaFactory::FORCE_SUBSCHEMA => true], Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'schemaFactory' => 'mcp', + 'namePrefix' => 'McpBook', + ]]; + + yield 'mcp_loader_collection_output' => ['mcp_loader_collection_output', McpBook::class, 'jsonld', Schema::TYPE_OUTPUT, 'list_books', [SchemaFactory::FORCE_SUBSCHEMA => true], Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'schemaFactory' => 'mcp', + 'collection' => true, + 'namePrefix' => 'McpBook', + ]]; + + yield 'mcp_loader_output_dto' => ['mcp_loader_output_dto', McpBookOutputDto::class, 'jsonld', Schema::TYPE_OUTPUT, 'list_books_dto', [SchemaFactory::FORCE_SUBSCHEMA => true], Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'jsonld', + 'schemaFactory' => 'mcp', + 'mcpResource' => McpBook::class, + 'namePrefix' => 'McpBook', + ]]; + + yield 'mcp_loader_input' => ['mcp_loader_input', SearchDto::class, 'jsonld', Schema::TYPE_INPUT, 'list_books', [SchemaFactory::FORCE_SUBSCHEMA => true], Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'schemaFactory' => 'mcp', + 'mcpResource' => McpBook::class, + ]]; } private static function normalize(mixed $value): mixed diff --git a/tests/Functional/JsonSchema/golden/jsonhal_output_collection.json b/tests/Functional/JsonSchema/golden/jsonhal_output_collection.json new file mode 100644 index 0000000000..b3cca4c61a --- /dev/null +++ b/tests/Functional/JsonSchema/golden/jsonhal_output_collection.json @@ -0,0 +1,174 @@ +{ + "definitions": { + "GoldenBook-golden.read": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "isbn": { + "deprecated": true, + "description": "International standard book number.", + "example": "9780000000000", + "type": [ + "string", + "null" + ] + }, + "title": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "GoldenBook.jsonhal-golden.read": { + "allOf": [ + { + "properties": { + "_links": { + "properties": { + "self": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + { + "$ref": "#/definitions/GoldenBook-golden.read" + } + ] + }, + "HalCollectionBaseSchema": { + "allOf": [ + { + "$ref": "#/definitions/HalCollectionBaseSchemaNoPagination" + }, + { + "properties": { + "_links": { + "properties": { + "first": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + }, + "last": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + }, + "next": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + }, + "previous": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + } + } + }, + "itemsPerPage": { + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + ] + }, + "HalCollectionBaseSchemaNoPagination": { + "properties": { + "_embedded": { + "anyOf": [ + { + "properties": { + "item": { + "type": "array" + } + }, + "type": "object" + }, + { + "type": "object" + } + ] + }, + "_links": { + "properties": { + "self": { + "properties": { + "href": { + "format": "iri-reference", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "totalItems": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "_links", + "_embedded" + ], + "type": "object" + } + }, + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "#/definitions/HalCollectionBaseSchema" + }, + { + "properties": { + "_embedded": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/GoldenBook.jsonhal-golden.read" + }, + "type": "array" + } + } + }, + "type": "object" + } + ], + "description": "GoldenBook.jsonhal-golden.read collection." + } +} diff --git a/tests/Functional/JsonSchema/golden/mcp_loader_collection_output.json b/tests/Functional/JsonSchema/golden/mcp_loader_collection_output.json new file mode 100644 index 0000000000..5a3d6ceb8e --- /dev/null +++ b/tests/Functional/JsonSchema/golden/mcp_loader_collection_output.json @@ -0,0 +1,186 @@ +{ + "definitions": [], + "schema": { + "description": "McpBook.jsonld collection.", + "properties": { + "hydra:member": { + "items": { + "description": "List Books", + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + }, + "id": { + "readOnly": true, + "type": "integer" + }, + "isbn": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "@id", + "@type" + ], + "type": "object" + }, + "type": "array" + }, + "hydra:search": { + "properties": { + "@type": { + "type": "string" + }, + "hydra:mapping": { + "items": { + "properties": { + "@type": { + "type": "string" + }, + "property": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": { + "type": "boolean" + }, + "variable": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "hydra:template": { + "type": "string" + }, + "hydra:variableRepresentation": { + "type": "string" + } + }, + "type": "object" + }, + "hydra:totalItems": { + "minimum": 0, + "type": "integer" + }, + "hydra:view": { + "example": { + "@id": "string", + "@type": "string", + "hydra:first": "string", + "hydra:last": "string", + "hydra:next": "string", + "hydra:previous": "string" + }, + "properties": { + "@id": { + "format": "iri-reference", + "type": "string" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "format": "iri-reference" + }, + "hydra:last": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "format": "iri-reference" + }, + "hydra:next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "format": "iri-reference" + }, + "hydra:previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "format": "iri-reference" + } + }, + "type": "object" + } + }, + "required": [ + "hydra:member" + ], + "type": "object" + } +} diff --git a/tests/Functional/JsonSchema/golden/mcp_loader_input.json b/tests/Functional/JsonSchema/golden/mcp_loader_input.json new file mode 100644 index 0000000000..3a6448d875 --- /dev/null +++ b/tests/Functional/JsonSchema/golden/mcp_loader_input.json @@ -0,0 +1,11 @@ +{ + "definitions": [], + "schema": { + "properties": { + "search": { + "type": "string" + } + }, + "type": "object" + } +} diff --git a/tests/Functional/JsonSchema/golden/mcp_loader_item_output.json b/tests/Functional/JsonSchema/golden/mcp_loader_item_output.json new file mode 100644 index 0000000000..b44e85adaf --- /dev/null +++ b/tests/Functional/JsonSchema/golden/mcp_loader_item_output.json @@ -0,0 +1,63 @@ +{ + "definitions": [], + "schema": { + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ], + "type": "string" + } + }, + "required": [ + "@vocab", + "hydra" + ], + "type": "object" + } + ] + }, + "@id": { + "type": "string" + }, + "@type": { + "type": "string" + }, + "id": { + "readOnly": true, + "type": "integer" + }, + "isbn": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "@type" + ], + "type": "object" + } +} diff --git a/tests/Functional/JsonSchema/golden/mcp_loader_output_dto.json b/tests/Functional/JsonSchema/golden/mcp_loader_output_dto.json new file mode 100644 index 0000000000..605a6be16e --- /dev/null +++ b/tests/Functional/JsonSchema/golden/mcp_loader_output_dto.json @@ -0,0 +1,17 @@ +{ + "definitions": [], + "schema": { + "properties": { + "id": { + "type": "integer" + }, + "isbn": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + } +} From dac0ca12966b2037c8b715cf2056dc95fbc30f97 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 7 Oct 2026 15:04:12 +0200 Subject: [PATCH 04/13] feat(symfony): opt-in json schema generator api_platform.json_schema.generator (off by default) swaps the base schema factory for the Symfony JsonSchema generator. Definition processors are autoconfigured, the Hydra item processor is tagged. --- .../JsonSchema/ItemDefinitionProcessor.php | 10 ++-- .../Generator/ApiPropertySchemaEnricher.php | 4 +- .../Generator/GeneratorSchemaFactory.php | 20 +++++-- .../ApiPlatformExtension.php | 21 ++++++++ .../DependencyInjection/Configuration.php | 6 +++ src/Symfony/Bundle/Resources/config/hydra.php | 8 +++ .../config/json_schema_generator.php | 47 ++++++++++++++++ .../ApiPlatformExtensionTest.php | 53 ++++++++++++++++++- .../JsonSchemaGolden/GoldenActivable.php | 18 +++++++ .../GoldenIntersectionResource.php | 30 +++++++++++ .../JsonSchemaGolden/GoldenTimestampable.php | 18 +++++++ tests/Fixtures/app/AppKernel.php | 3 ++ .../JsonSchema/GoldenSchemaTest.php | 14 ++++- .../golden/output_intersection_type.json | 35 ++++++++++++ .../DependencyInjection/ConfigurationTest.php | 3 ++ 15 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 src/Symfony/Bundle/Resources/config/json_schema_generator.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenActivable.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenIntersectionResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenTimestampable.php create mode 100644 tests/Functional/JsonSchema/golden/output_intersection_type.json diff --git a/src/Hydra/JsonSchema/ItemDefinitionProcessor.php b/src/Hydra/JsonSchema/ItemDefinitionProcessor.php index 560dd6a376..a73709361b 100644 --- a/src/Hydra/JsonSchema/ItemDefinitionProcessor.php +++ b/src/Hydra/JsonSchema/ItemDefinitionProcessor.php @@ -14,22 +14,26 @@ namespace ApiPlatform\Hydra\JsonSchema; use ApiPlatform\JsonSchema\Generator\ApiPlatformDefinitionPolicy; -use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Symfony\Component\JsonSchema\Configuration; use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; final class ItemDefinitionProcessor implements DefinitionProcessorInterface { + use ResourceClassInfoTrait; + public function __construct( - private readonly ResourceClassResolverInterface $resourceClassResolver, + ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly ApiPlatformDefinitionPolicy $definitionPolicy, ) { + $this->resourceMetadataFactory = $resourceMetadataFactory; } public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array { - if (null === $parent || 'jsonld' !== $config->format || !$this->resourceClassResolver->isResourceClass($class)) { + if (null === $parent || 'jsonld' !== $config->format || !$this->isResourceClass($class)) { return $definition; } diff --git a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php index 2d2e1027a2..cff0c74490 100644 --- a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php +++ b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php @@ -45,7 +45,9 @@ public function enrich(PropertySchema $property, Configuration $config): Propert } $contextSchema = Schema::VERSION_JSON_SCHEMA === $this->version ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); - $schema = [...array_diff_key($property->schema, ['default' => true]), ...array_diff_key($metadata->getSchema() ?? [], array_flip(self::OWNED_KEYWORDS)), ...$this->getDocumentation($metadata)]; + $metadataSchema = $metadata->getSchema() ?? []; + $schema = [...array_diff_key($property->schema, ['default' => true]), ...array_diff_key($metadataSchema, array_flip(self::OWNED_KEYWORDS)), ...$this->getDocumentation($metadata)]; + $schema = array_replace(array_intersect_key($metadataSchema, $schema), $schema); if ($contextSchema) { if (array_intersect_key($contextSchema, array_flip(self::STRUCTURAL_KEYWORDS))) { diff --git a/src/JsonSchema/Generator/GeneratorSchemaFactory.php b/src/JsonSchema/Generator/GeneratorSchemaFactory.php index d04c4caf58..5302cc2d40 100644 --- a/src/JsonSchema/Generator/GeneratorSchemaFactory.php +++ b/src/JsonSchema/Generator/GeneratorSchemaFactory.php @@ -82,13 +82,27 @@ public function buildSchema(string $className, string $format = 'json', string $ ]); } - foreach ($definition['properties'] ?? [] as $property => $propertySchema) { - $definition['properties'][$property] = new \ArrayObject($propertySchema); + foreach ($definition['allOf'] ?? [] as $key => $member) { + if (isset($member['properties'])) { + $definition['allOf'][$key] = self::toDefinitionObject($member); + } } - $definitions[$name] = new \ArrayObject($definition); + $definitions[$name] = self::toDefinitionObject($definition); } return $schema; } + + /** + * @param array $definition + */ + private static function toDefinitionObject(array $definition): \ArrayObject + { + foreach ($definition['properties'] ?? [] as $property => $propertySchema) { + $definition['properties'][$property] = new \ArrayObject($propertySchema); + } + + return new \ArrayObject($definition); + } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 0b2548a3ff..7bc0d1c937 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -84,6 +84,8 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Finder\Finder; use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; +use Symfony\Component\JsonSchema\SchemaGenerator; use Symfony\Component\JsonStreamer\JsonStreamWriter; use Symfony\Component\ObjectMapper\ObjectMapper; use Symfony\Component\ObjectMapper\ObjectMapperInterface; @@ -235,6 +237,11 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(ParameterProviderInterface::class) ->addTag('api_platform.parameter_provider'); + if (interface_exists(DefinitionProcessorInterface::class)) { + $container->registerForAutoconfiguration(DefinitionProcessorInterface::class) + ->addTag('api_platform.json_schema.definition_processor'); + } + $container->registerAttributeForAutoconfiguration( AsResourceMutator::class, static function (ChildDefinition $definition, AsResourceMutator $attribute, \ReflectionClass $reflector): void { // @phpstan-ignore-line @@ -746,6 +753,10 @@ private function registerJsonLdHydraConfiguration(ContainerBuilder $container, a if (!$container->has('api_platform.json_schema.schema_factory')) { $container->removeDefinition('api_platform.hydra.json_schema.schema_factory'); } + + if (!$config['json_schema']['generator']) { + $container->removeDefinition('api_platform.hydra.json_schema.item_definition_processor'); + } } private function registerJsonHalConfiguration(array $formats, PhpFileLoader $loader): void @@ -1111,6 +1122,16 @@ private function registerOpenApiConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.openapi.validationErrorResourceClass', $config['openapi']['validation_error_resource_class'] ?? null); $loader->load('json_schema.php'); + + if (!$config['json_schema']['generator']) { + return; + } + + if (!class_exists(SchemaGenerator::class)) { + throw new \LogicException('The "api_platform.json_schema.generator" option requires the Symfony JsonSchema component. Try running "composer require symfony/json-schema".'); + } + + $loader->load('json_schema_generator.php'); } private function registerMakerConfiguration(ContainerBuilder $container, array $config, PhpFileLoader $loader): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 10db1dde0d..c3f04c2312 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -215,6 +215,12 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue([]) ->info('The JSON formats to compute the JSON Schemas for.') ->end() + ->arrayNode('json_schema') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('generator')->defaultFalse()->info('Build JSON Schemas with the Symfony JsonSchema component (experimental, requires symfony/json-schema).')->end() + ->end() + ->end() ->end(); $this->addDefaultsSection($rootNode); diff --git a/src/Symfony/Bundle/Resources/config/hydra.php b/src/Symfony/Bundle/Resources/config/hydra.php index 1ce0d98c9d..4a808d2581 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\Hydra\JsonSchema\ItemDefinitionProcessor; use ApiPlatform\Hydra\JsonSchema\SchemaFactory; use ApiPlatform\Hydra\Serializer\CollectionFiltersNormalizer; use ApiPlatform\Hydra\Serializer\CollectionNormalizer; @@ -35,6 +36,13 @@ service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), ]); + $services->set('api_platform.hydra.json_schema.item_definition_processor', ItemDefinitionProcessor::class) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.json_schema.generator.definition_policy'), + ]) + ->tag('api_platform.json_schema.definition_processor'); + $services->set('api_platform.hydra.normalizer.documentation', DocumentationNormalizer::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/src/Symfony/Bundle/Resources/config/json_schema_generator.php b/src/Symfony/Bundle/Resources/config/json_schema_generator.php new file mode 100644 index 0000000000..769474a1ce --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_schema_generator.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\JsonSchema\Generator\ApiPlatformDefinitionPolicy; +use ApiPlatform\JsonSchema\Generator\ConfigurationFactory; +use ApiPlatform\JsonSchema\Generator\GeneratorSchemaFactory; + +return static function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.json_schema.generator.definition_policy', ApiPlatformDefinitionPolicy::class) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.resource_class_resolver'), + service('api_platform.metadata.property.metadata_factory'), + ]); + + $services->set('api_platform.json_schema.generator.configuration_factory', ConfigurationFactory::class) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.resource_class_resolver'), + service('api_platform.json_schema.generator.definition_policy'), + ]); + + $services->set('api_platform.json_schema.schema_factory', GeneratorSchemaFactory::class) + ->args([ + service('api_platform.json_schema.generator.configuration_factory'), + service('api_platform.json_schema.generator.definition_policy'), + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.resource_class_resolver'), + service('api_platform.name_converter')->ignoreOnInvalid(), + tagged_iterator('api_platform.json_schema.definition_processor'), + ]); +}; diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 6a0b2f737d..54fcb70ef2 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -13,6 +13,11 @@ namespace ApiPlatform\Symfony\Tests\Bundle\DependencyInjection; +use ApiPlatform\Hydra\JsonSchema\ItemDefinitionProcessor; +use ApiPlatform\JsonSchema\Generator\ApiPlatformDefinitionPolicy; +use ApiPlatform\JsonSchema\Generator\ConfigurationFactory; +use ApiPlatform\JsonSchema\Generator\GeneratorSchemaFactory; +use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\Metadata\Exception\ExceptionInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\IdentifiersExtractorInterface; @@ -33,10 +38,12 @@ use Symfony\AI\McpBundle\McpBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; class ApiPlatformExtensionTest extends TestCase @@ -451,6 +458,50 @@ public function testMcpProviderChainIsNotSecuredWithoutSecurityBundle(): void $this->assertContainerHasService('api_platform.mcp.state_provider.parameter_validator'); } + public function testJsonSchemaGeneratorIsDisabledByDefault(): void + { + (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); + + $this->assertSame(SchemaFactory::class, $this->container->getDefinition('api_platform.json_schema.schema_factory')->getClass()); + $this->assertNotContainerHasService('api_platform.json_schema.generator.definition_policy'); + $this->assertNotContainerHasService('api_platform.json_schema.generator.configuration_factory'); + $this->assertNotContainerHasService('api_platform.hydra.json_schema.item_definition_processor'); + $this->assertSame([], $this->container->findTaggedServiceIds('api_platform.json_schema.definition_processor')); + } + + public function testJsonSchemaGeneratorReplacesTheBaseSchemaFactory(): void + { + $config = self::DEFAULT_CONFIG; + $config['api_platform']['json_schema']['generator'] = true; + (new ApiPlatformExtension())->load($config, $this->container); + + $schemaFactory = $this->container->getDefinition('api_platform.json_schema.schema_factory'); + $this->assertSame(GeneratorSchemaFactory::class, $schemaFactory->getClass()); + $this->assertSame(ApiPlatformDefinitionPolicy::class, $this->container->getDefinition('api_platform.json_schema.generator.definition_policy')->getClass()); + $this->assertTrue($this->container->getDefinition('api_platform.json_schema.generator.definition_policy')->isShared()); + $this->assertSame(ConfigurationFactory::class, $this->container->getDefinition('api_platform.json_schema.generator.configuration_factory')->getClass()); + + $definitionProcessors = $schemaFactory->getArgument(6); + $this->assertInstanceOf(TaggedIteratorArgument::class, $definitionProcessors); + $this->assertSame('api_platform.json_schema.definition_processor', $definitionProcessors->getTag()); + + $this->assertSame(ItemDefinitionProcessor::class, $this->container->getDefinition('api_platform.hydra.json_schema.item_definition_processor')->getClass()); + $this->assertServiceHasTags('api_platform.hydra.json_schema.item_definition_processor', ['api_platform.json_schema.definition_processor']); + + $this->assertSame('api_platform.json_schema.schema_factory', $this->container->getDefinition('api_platform.hydra.json_schema.schema_factory')->getDecoratedService()[0]); + $this->assertSame('api_platform.json_schema.schema_factory', $this->container->getDefinition('api_platform.hal.json_schema.schema_factory')->getDecoratedService()[0]); + $this->assertSame('api_platform.json_schema.schema_factory', $this->container->getDefinition('api_platform.json_schema.backward_compatible_schema_factory')->getDecoratedService()[0]); + } + + public function testJsonSchemaDefinitionProcessorsAreAutoconfigured(): void + { + (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); + + $instanceof = $this->container->getAutoconfiguredInstanceof(); + $this->assertArrayHasKey(DefinitionProcessorInterface::class, $instanceof); + $this->assertArrayHasKey('api_platform.json_schema.definition_processor', $instanceof[DefinitionProcessorInterface::class]->getTags()); + } + public function testItRegistersMetadataConfiguration(): void { $config = self::DEFAULT_CONFIG; @@ -535,7 +586,7 @@ public function testPropertyInfoExtractorsDoNotLeakIntoFrameworkPropertyInfo(): $apiPlatformPropertyInfo = $this->container->getDefinition('api_platform.property_info'); foreach ($apiPlatformPropertyInfo->getArguments() as $arg) { - if ($arg instanceof \Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument) { + if ($arg instanceof TaggedIteratorArgument) { $this->assertStringStartsWith('api_platform.property_info.', $arg->getTag(), \sprintf('api_platform.property_info must consume only "api_platform.property_info.*" private tags; found "%s".', $arg->getTag())); } } diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenActivable.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenActivable.php new file mode 100644 index 0000000000..55274c3c5f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenActivable.php @@ -0,0 +1,18 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +interface GoldenActivable +{ +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenIntersectionResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenIntersectionResource.php new file mode 100644 index 0000000000..4f93bfb460 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenIntersectionResource.php @@ -0,0 +1,30 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'GoldenIntersection', + operations: [ + new Get(uriTemplate: '/golden_intersections/{id}', name: 'golden_intersection_get'), + ], +)] +final class GoldenIntersectionResource +{ + public ?int $id = null; + + public GoldenActivable&GoldenTimestampable $library; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenTimestampable.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenTimestampable.php new file mode 100644 index 0000000000..69e124850e --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGolden/GoldenTimestampable.php @@ -0,0 +1,18 @@ + + * + * 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\Fixtures\TestBundle\ApiResource\JsonSchemaGolden; + +interface GoldenTimestampable +{ +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index e670b1c9de..1a3c7ea88e 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -262,6 +262,9 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ 'max_query_depth' => 200, ], 'use_symfony_listeners' => $useSymfonyListeners, + 'json_schema' => [ + 'generator' => (bool) ($_SERVER['JSON_SCHEMA_GENERATOR'] ?? false), + ], 'defaults' => [ 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, diff --git a/tests/Functional/JsonSchema/GoldenSchemaTest.php b/tests/Functional/JsonSchema/GoldenSchemaTest.php index dc497730f6..93e61ca2a2 100644 --- a/tests/Functional/JsonSchema/GoldenSchemaTest.php +++ b/tests/Functional/JsonSchema/GoldenSchemaTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenDescribedRelated; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenGroupedItem; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenIntersectionResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenMultiResourceEntity; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenMultiResourceOwner; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGolden\GoldenNestedResource; @@ -85,6 +86,7 @@ public static function getResources(): array GoldenDescribedRelated::class, GoldenGroupedItem::class, GoldenInputResource::class, + GoldenIntersectionResource::class, GoldenMultiResourceEntity::class, GoldenMultiResourceOwner::class, GoldenNestedResource::class, @@ -110,6 +112,10 @@ public static function getResources(): array #[DataProvider('provideCases')] public function testBuildSchemaMatchesGolden(string $case, string $className, string $format, string $type, ?string $operationName, ?array $serializerContext, string $version, array $configuration): void { + if ('output_intersection_type' === $case) { + $this->markTestSkipped('The old path emits anyOf for intersection types, fixed on 5.0 by fix/json-schema-intersection-allof.'); + } + $this->assertNotEmpty($configuration); $operation = null; @@ -174,7 +180,7 @@ public function testGenerateMatchesGolden(string $case, string $className, strin $propertyMetadataFactory, $resourceClassResolver, $container->has('api_platform.name_converter') ? $container->get('api_platform.name_converter') : null, - [new ItemDefinitionProcessor($resourceClassResolver, $policy)], + [new ItemDefinitionProcessor($metadataFactory, $policy)], ); $definitionNameFactory = $container->get('api_platform.json_schema.definition_name_factory'); $factory = new JsonApiSchemaFactory($factory, $propertyMetadataFactory, $resourceClassResolver, $metadataFactory, $definitionNameFactory, $container->get('api_platform.jsonapi.resource_linkage_resolver')); @@ -461,6 +467,12 @@ public static function provideCases(): iterable 'namePrefix' => 'BuiltinTypeQuirks', ]]; + yield 'output_intersection_type' => ['output_intersection_type', GoldenIntersectionResource::class, 'json', Schema::TYPE_OUTPUT, 'golden_intersection_get', null, Schema::VERSION_JSON_SCHEMA, [ + 'dialect' => 'jsonSchema202012', + 'format' => 'json', + 'namePrefix' => 'GoldenIntersection', + ]]; + yield 'nested_described_resource' => ['nested_described_resource', GoldenDescribedOwner::class, 'json', Schema::TYPE_OUTPUT, 'golden_described_owner_get', null, Schema::VERSION_JSON_SCHEMA, [ 'dialect' => 'jsonSchema202012', 'format' => 'json', diff --git a/tests/Functional/JsonSchema/golden/output_intersection_type.json b/tests/Functional/JsonSchema/golden/output_intersection_type.json new file mode 100644 index 0000000000..236a34ddce --- /dev/null +++ b/tests/Functional/JsonSchema/golden/output_intersection_type.json @@ -0,0 +1,35 @@ +{ + "definitions": { + "GoldenActivable": { + "type": "object" + }, + "GoldenIntersection": { + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "library": { + "allOf": [ + { + "$ref": "#/definitions/GoldenActivable" + }, + { + "$ref": "#/definitions/GoldenTimestampable" + } + ] + } + }, + "type": "object" + }, + "GoldenTimestampable": { + "type": "object" + } + }, + "schema": { + "$ref": "#/definitions/GoldenIntersection", + "$schema": "http://json-schema.org/draft-07/schema#" + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 0d47ca31b7..5b19d7540a 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -99,6 +99,9 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'json' => ['mime_types' => ['application/problem+json', 'application/json']], ], 'jsonschema_formats' => [], + 'json_schema' => [ + 'generator' => false, + ], 'exception_to_status' => [ ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, From a7a3f92f649c5d84f6a8e88242a5dc2e7e9fd109 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 7 Oct 2026 15:51:35 +0200 Subject: [PATCH 05/13] refactor(jsonschema): follow JsonSchema spike API --- .../Generator/ConfigurationFactory.php | 22 +++++++++---------- .../Generator/GeneratorSchemaFactory.php | 3 +-- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/JsonSchema/Generator/ConfigurationFactory.php b/src/JsonSchema/Generator/ConfigurationFactory.php index 509c01d58b..3626b944c9 100644 --- a/src/JsonSchema/Generator/ConfigurationFactory.php +++ b/src/JsonSchema/Generator/ConfigurationFactory.php @@ -24,7 +24,6 @@ use Symfony\Component\JsonSchema\Configuration; use Symfony\Component\JsonSchema\Dialect; use Symfony\Component\JsonSchema\NullSyntax; -use Symfony\Component\JsonSchema\ReferenceStrategy; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraints\GroupSequence; @@ -82,16 +81,15 @@ public function create(string $className, string $format, string $type, ?Operati $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES] ?? null; $configuration = new Configuration( - $this->createDialect($version), - ReferenceStrategy::ByDefinition, - array_values((array) ($serializerContext[AbstractNormalizer::GROUPS] ?? [])), - $attributes ? (array) $attributes : null, - array_values((array) ($serializerContext['ignored_attributes'] ?? [])), - false !== ($serializerContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] ?? true), - $this->normalizeValidationGroups($validationGroups), - $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null, - $this->definitionPolicy->createRootPrefix($operation, $className, $inputOrOutputClass), - $isJsonMergePatch ? 'merge-patch+json' : $format, + dialect: $this->createDialect($version), + groups: array_values((array) ($serializerContext[AbstractNormalizer::GROUPS] ?? [])), + attributes: $attributes ? (array) $attributes : null, + ignoredAttributes: array_values((array) ($serializerContext['ignored_attributes'] ?? [])), + allowExtraAttributes: false !== ($serializerContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] ?? true), + validationGroups: $this->normalizeValidationGroups($validationGroups), + definitionName: $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null, + definitionPrefix: $this->definitionPolicy->createRootPrefix($operation, $className, $inputOrOutputClass), + format: $isJsonMergePatch ? 'merge-patch+json' : $format, ); $propertyOptions = ['schema_type' => $type, 'enable_getter_setter_extraction' => true]; @@ -123,7 +121,7 @@ private function createDialect(string $version): Dialect { return match ($version) { Schema::VERSION_OPENAPI => new Dialect('#/components/schemas/', NullSyntax::Union), - Schema::VERSION_SWAGGER => new Dialect('#/definitions/', NullSyntax::Union, supportsDeprecated: false), + Schema::VERSION_SWAGGER => new Dialect('#/definitions/', NullSyntax::Union), default => new Dialect('#/definitions/', NullSyntax::Union, schemaUri: 'http://json-schema.org/draft-07/schema#'), }; } diff --git a/src/JsonSchema/Generator/GeneratorSchemaFactory.php b/src/JsonSchema/Generator/GeneratorSchemaFactory.php index 5302cc2d40..1f6884f3cb 100644 --- a/src/JsonSchema/Generator/GeneratorSchemaFactory.php +++ b/src/JsonSchema/Generator/GeneratorSchemaFactory.php @@ -20,7 +20,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\JsonSchema\ClassSchemaResolver\NativeClassSchemaResolver; -use Symfony\Component\JsonSchema\ClassSchemaResolver\UidClassSchemaResolver; use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; use Symfony\Component\JsonSchema\SchemaGenerator; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -54,7 +53,7 @@ public function buildSchema(string $className, string $format = 'json', string $ $generator = new SchemaGenerator( new ApiPlatformPropertyInfoExtractor($accessor), $policy, - [new ResourceIriClassSchemaResolver($accessor, $this->resourceClassResolver, $type), new NativeClassSchemaResolver(), new UidClassSchemaResolver()], + [new ResourceIriClassSchemaResolver($accessor, $this->resourceClassResolver, $type), new NativeClassSchemaResolver()], [new ApiPropertySchemaProvider($accessor, $request->version)], [new ApiPropertySchemaEnricher($accessor, $this->resourceClassResolver, $request->version)], [new BuiltinTypeDefinitionProcessor($accessor, $this->nameConverter), new PartialUpdateDefinitionProcessor($policy), new NestedOperationDefinitionProcessor($policy), ...$this->definitionProcessors], From 09ef76ba71a4bac91ae281646ea7c587bb76f30f Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 8 Oct 2026 11:18:12 +0200 Subject: [PATCH 06/13] refactor(jsonschema): drop callable validation groups --- src/JsonSchema/Generator/ConfigurationFactory.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/JsonSchema/Generator/ConfigurationFactory.php b/src/JsonSchema/Generator/ConfigurationFactory.php index 3626b944c9..69acc369a8 100644 --- a/src/JsonSchema/Generator/ConfigurationFactory.php +++ b/src/JsonSchema/Generator/ConfigurationFactory.php @@ -127,14 +127,13 @@ private function createDialect(string $version): Dialect } /** - * @return array|GroupSequence|\Closure|null + * @return array|GroupSequence|null */ - private function normalizeValidationGroups(mixed $groups): array|GroupSequence|\Closure|null + private function normalizeValidationGroups(mixed $groups): array|GroupSequence|null { return match (true) { - null === $groups => null, - $groups instanceof GroupSequence, $groups instanceof \Closure => $groups, - \is_array($groups) && \is_callable($groups) => \Closure::fromCallable($groups), + null === $groups, $groups instanceof \Closure, \is_array($groups) && \is_callable($groups) => null, + $groups instanceof GroupSequence => $groups, \is_array($groups) => $groups, default => [$groups], }; From a6c06ee53b213153c1fd428503db65d8ab5c276a Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2026 11:15:07 +0200 Subject: [PATCH 07/13] refactor(jsonschema): follow JsonSchema extension namespaces --- src/Hydra/JsonSchema/ItemDefinitionProcessor.php | 5 +++-- src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php | 5 +++-- src/JsonSchema/Generator/ApiPropertySchemaEnricher.php | 7 ++++--- src/JsonSchema/Generator/ApiPropertySchemaProvider.php | 5 +++-- .../Generator/BuiltinTypeDefinitionProcessor.php | 5 +++-- .../Generator/NestedOperationDefinitionProcessor.php | 5 +++-- .../Generator/PartialUpdateDefinitionProcessor.php | 5 +++-- .../Generator/ResourceIriClassSchemaResolver.php | 5 +++-- 8 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/Hydra/JsonSchema/ItemDefinitionProcessor.php b/src/Hydra/JsonSchema/ItemDefinitionProcessor.php index a73709361b..79dfe6eb46 100644 --- a/src/Hydra/JsonSchema/ItemDefinitionProcessor.php +++ b/src/Hydra/JsonSchema/ItemDefinitionProcessor.php @@ -17,8 +17,9 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionParent; use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; +use Symfony\Component\JsonSchema\SchemaDocument; final class ItemDefinitionProcessor implements DefinitionProcessorInterface { @@ -31,7 +32,7 @@ public function __construct( $this->resourceMetadataFactory = $resourceMetadataFactory; } - public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array { if (null === $parent || 'jsonld' !== $config->format || !$this->isResourceClass($class)) { return $definition; diff --git a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php index 5a11d5812e..0aa71ecf54 100644 --- a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php +++ b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php @@ -23,8 +23,9 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionParent; use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionPolicyInterface; +use Symfony\Component\JsonSchema\SchemaDocument; final class ApiPlatformDefinitionPolicy implements DefinitionPolicyInterface { @@ -94,7 +95,7 @@ public function createRootPrefix(?Operation $operation, string $className, strin return $prefix; } - public function nameFor(string $class, Configuration $config, ?DefinitionParent $parent = null): string + public function nameFor(string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent = null): string { $isPartialUpdate = $this->isPartial($class, $parent); diff --git a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php index cff0c74490..7b4788551d 100644 --- a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php +++ b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php @@ -18,8 +18,9 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\Enricher\PropertySchema; -use Symfony\Component\JsonSchema\Enricher\PropertySchemaEnricherInterface; +use Symfony\Component\JsonSchema\PropertySchemaEnricher\PropertySchema; +use Symfony\Component\JsonSchema\PropertySchemaEnricher\PropertySchemaEnricherInterface; +use Symfony\Component\JsonSchema\SchemaDocument; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -35,7 +36,7 @@ public function __construct( ) { } - public function enrich(PropertySchema $property, Configuration $config): PropertySchema + public function enrich(PropertySchema $property, Configuration $config, SchemaDocument $document): PropertySchema { $metadata = $this->accessor->get($property->class, $property->property, PropertyMetadataAccessor::contextFromConfiguration($config)); $required = true === $metadata->isRequired(); diff --git a/src/JsonSchema/Generator/ApiPropertySchemaProvider.php b/src/JsonSchema/Generator/ApiPropertySchemaProvider.php index a887cebd94..091c33257e 100644 --- a/src/JsonSchema/Generator/ApiPropertySchemaProvider.php +++ b/src/JsonSchema/Generator/ApiPropertySchemaProvider.php @@ -16,7 +16,8 @@ use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; use ApiPlatform\JsonSchema\Schema; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\Enricher\PropertySchemaProviderInterface; +use Symfony\Component\JsonSchema\PropertySchemaProvider\PropertySchemaProviderInterface; +use Symfony\Component\JsonSchema\SchemaDocument; final class ApiPropertySchemaProvider implements PropertySchemaProviderInterface { @@ -26,7 +27,7 @@ public function __construct( ) { } - public function provide(string $class, string $property, Configuration $config): ?array + public function provide(string $class, string $property, Configuration $config, SchemaDocument $document): ?array { $metadata = $this->accessor->get($class, $property, PropertyMetadataAccessor::contextFromConfiguration($config)); diff --git a/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php b/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php index e769a421ab..0e571a2f01 100644 --- a/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php +++ b/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php @@ -14,9 +14,10 @@ namespace ApiPlatform\JsonSchema\Generator; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionParent; use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; use Symfony\Component\JsonSchema\NullSyntax; +use Symfony\Component\JsonSchema\SchemaDocument; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\NullableType; @@ -32,7 +33,7 @@ public function __construct( ) { } - public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array { if (!isset($definition['properties'])) { return $definition; diff --git a/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php b/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php index 56a8de9024..04e5ede557 100644 --- a/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php +++ b/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php @@ -15,8 +15,9 @@ use ApiPlatform\Metadata\HttpOperation; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionParent; use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; +use Symfony\Component\JsonSchema\SchemaDocument; final class NestedOperationDefinitionProcessor implements DefinitionProcessorInterface { @@ -24,7 +25,7 @@ public function __construct(private readonly ApiPlatformDefinitionPolicy $defini { } - public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array { if (null === $parent || null === $operation = $this->definitionPolicy->findNestedOperation($class)) { return $definition; diff --git a/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php b/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php index 99a353334a..7f0451b786 100644 --- a/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php +++ b/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php @@ -14,8 +14,9 @@ namespace ApiPlatform\JsonSchema\Generator; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionParent; use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; +use Symfony\Component\JsonSchema\SchemaDocument; final class PartialUpdateDefinitionProcessor implements DefinitionProcessorInterface { @@ -23,7 +24,7 @@ public function __construct(private readonly ApiPlatformDefinitionPolicy $defini { } - public function process(array $definition, string $class, Configuration $config, ?DefinitionParent $parent): array + public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array { if ($this->definitionPolicy->isPartial($class, $parent)) { unset($definition['required']); diff --git a/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php b/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php index 9c75239dc3..e94bc73247 100644 --- a/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php +++ b/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php @@ -18,7 +18,8 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\JsonSchema\ClassSchemaResolver\ClassSchemaResolverInterface; use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\DefinitionPolicy\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionParent; +use Symfony\Component\JsonSchema\SchemaDocument; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -31,7 +32,7 @@ public function __construct( ) { } - public function resolve(string $class, Configuration $config, ?DefinitionParent $parent = null): ?array + public function resolve(string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent = null): ?array { if (null === $parent || !$this->resourceClassResolver->isResourceClass($class)) { return null; From 96bc76398e557624d4db5534011d9e22f22667a4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2026 11:15:07 +0200 Subject: [PATCH 08/13] fix(jsonschema): keep declared group order in names --- .../Generator/ApiPlatformDefinitionPolicy.php | 16 +++++++++++++++- .../Generator/ConfigurationFactory.php | 5 ++++- .../Generator/SchemaGenerationRequest.php | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php index 0aa71ecf54..a0f6e5f1d0 100644 --- a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php +++ b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php @@ -121,7 +121,7 @@ public function nameFor(string $class, Configuration $config, SchemaDocument $do $parts = []; if ($config->groups) { - $parts[] = implode('_', $config->groups); + $parts[] = implode('_', $this->orderGroups($config->groups)); } if ($config->attributes) { @@ -254,4 +254,18 @@ private function getAttributesAsString(array $attributes): string return implode('_', $parts); } + + /** + * @param list $groups + * + * @return list + */ + private function orderGroups(array $groups): array + { + if (null === $this->request) { + return $groups; + } + + return array_values([...array_intersect($this->request->groups, $groups), ...array_diff($groups, $this->request->groups)]); + } } diff --git a/src/JsonSchema/Generator/ConfigurationFactory.php b/src/JsonSchema/Generator/ConfigurationFactory.php index 69acc369a8..de870cf700 100644 --- a/src/JsonSchema/Generator/ConfigurationFactory.php +++ b/src/JsonSchema/Generator/ConfigurationFactory.php @@ -80,9 +80,11 @@ public function create(string $className, string $format, string $type, ?Operati $validationGroups = $operation?->getValidationContext()['groups'] ?? null; $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES] ?? null; + $groups = array_values((array) ($serializerContext[AbstractNormalizer::GROUPS] ?? [])); + $configuration = new Configuration( dialect: $this->createDialect($version), - groups: array_values((array) ($serializerContext[AbstractNormalizer::GROUPS] ?? [])), + groups: $groups, attributes: $attributes ? (array) $attributes : null, ignoredAttributes: array_values((array) ($serializerContext['ignored_attributes'] ?? [])), allowExtraAttributes: false !== ($serializerContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] ?? true), @@ -114,6 +116,7 @@ public function create(string $className, string $format, string $type, ?Operati $operation?->getDescription() ?: null, Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason() && ($operationWasProvided || !($serializerContext[SchemaFactoryInterface::FORCE_SUBSCHEMA] ?? false)), $operation instanceof HttpOperation ? ($operation->getTypes()[0] ?? null) : null, + $groups, ); } diff --git a/src/JsonSchema/Generator/SchemaGenerationRequest.php b/src/JsonSchema/Generator/SchemaGenerationRequest.php index 07beb9c3a4..7e86acd0ab 100644 --- a/src/JsonSchema/Generator/SchemaGenerationRequest.php +++ b/src/JsonSchema/Generator/SchemaGenerationRequest.php @@ -20,6 +20,7 @@ { /** * @param array $propertyOptions + * @param list $groups */ public function __construct( public Type $type, @@ -32,6 +33,7 @@ public function __construct( public ?string $description = null, public bool $deprecated = false, public ?string $externalDocs = null, + public array $groups = [], ) { } } From 500ae30a52170c89ad9676ebfe1e9be58a0fd29e Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2026 11:55:02 +0200 Subject: [PATCH 09/13] refactor(jsonschema): carry request state in configuration context --- .../Generator/ApiPlatformDefinitionPolicy.php | 45 ++++++--------- .../Generator/ConfigurationFactory.php | 55 +++++++++++-------- .../Generator/GeneratorSchemaFactory.php | 26 +++++---- .../NestedOperationDefinitionProcessor.php | 2 +- .../PartialUpdateDefinitionProcessor.php | 2 +- .../Generator/SchemaGenerationRequest.php | 39 ------------- 6 files changed, 65 insertions(+), 104 deletions(-) delete mode 100644 src/JsonSchema/Generator/SchemaGenerationRequest.php diff --git a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php index a0f6e5f1d0..12d0471e4d 100644 --- a/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php +++ b/src/JsonSchema/Generator/ApiPlatformDefinitionPolicy.php @@ -36,11 +36,9 @@ final class ApiPlatformDefinitionPolicy implements DefinitionPolicyInterface private const JSON_MERGE_PATCH_SCHEMA_POSTFIX = 'jsonMergePatch'; /** - * @var \ArrayObject + * @var array */ - private \ArrayObject $prefixCache; - - private ?SchemaGenerationRequest $request = null; + private array $prefixCache = []; public function __construct( ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, @@ -49,15 +47,6 @@ public function __construct( ) { $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceClassResolver = $resourceClassResolver; - $this->prefixCache = new \ArrayObject(); - } - - public function forRequest(SchemaGenerationRequest $request): self - { - $policy = clone $this; - $policy->request = $request; - - return $policy; } public function isJsonMergePatch(?Operation $operation, string $type, string $format): bool @@ -72,15 +61,15 @@ public function isPartialUpdate(?Operation $operation, string $type, string $for return $this->isJsonMergePatch($operation, $type, $format) || $isNonStandardPut; } - public function isPartial(string $class, ?DefinitionParent $parent): bool + public function isPartial(string $class, Configuration $config, ?DefinitionParent $parent): bool { if (null === $parent) { - return $this->request->partial ?? false; + return $config->context[ConfigurationFactory::CONTEXT_PARTIAL] ?? false; } - $operation = $this->findNestedOperation($class); + $operation = $this->findNestedOperation($class, $config); - return null !== $operation && $this->isPartialUpdate($operation, $this->request->schemaType ?? Schema::TYPE_OUTPUT, $this->request->format ?? 'json'); + return null !== $operation && $this->isPartialUpdate($operation, $config->context[ConfigurationFactory::CONTEXT_SCHEMA_TYPE] ?? Schema::TYPE_OUTPUT, $config->context[ConfigurationFactory::CONTEXT_FORMAT] ?? 'json'); } public function createRootPrefix(?Operation $operation, string $className, string $inputOrOutputClass): string @@ -97,16 +86,16 @@ public function createRootPrefix(?Operation $operation, string $className, strin public function nameFor(string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent = null): string { - $isPartialUpdate = $this->isPartial($class, $parent); + $isPartialUpdate = $this->isPartial($class, $config, $parent); if (null === $parent) { $prefix = $config->definitionPrefix ?? $this->createPrefixFromClass($class); $isJsonMergePatch = self::JSON_MERGE_PATCH_FORMAT === $config->format; } else { - $operation = $this->findNestedOperation($class); + $operation = $this->findNestedOperation($class, $config); $prefix = $this->createNestedPrefix($class, $operation); - $type = $this->request->schemaType ?? Schema::TYPE_OUTPUT; - $format = $this->request->format ?? 'json'; + $type = $config->context[ConfigurationFactory::CONTEXT_SCHEMA_TYPE] ?? Schema::TYPE_OUTPUT; + $format = $config->context[ConfigurationFactory::CONTEXT_FORMAT] ?? 'json'; $isJsonMergePatch = null !== $operation && $this->isJsonMergePatch($operation, $type, $format); } @@ -121,7 +110,7 @@ public function nameFor(string $class, Configuration $config, SchemaDocument $do $parts = []; if ($config->groups) { - $parts[] = implode('_', $this->orderGroups($config->groups)); + $parts[] = implode('_', $this->orderGroups($config->groups, $config)); } if ($config->attributes) { @@ -167,13 +156,13 @@ public function resolveGenId(?DefinitionParent $parent): bool return true; } - public function findNestedOperation(string $class): ?Operation + public function findNestedOperation(string $class, Configuration $config): ?Operation { if (!$this->isResourceClass($class)) { return null; } - return $this->findOperation($class, $this->request->schemaType ?? Schema::TYPE_OUTPUT, null, [SchemaFactoryInterface::FORCE_SUBSCHEMA => true]); + return $this->findOperation($class, $config->context[ConfigurationFactory::CONTEXT_SCHEMA_TYPE] ?? Schema::TYPE_OUTPUT, null, [SchemaFactoryInterface::FORCE_SUBSCHEMA => true]); } private function createNestedPrefix(string $class, ?Operation $operation): string @@ -260,12 +249,10 @@ private function getAttributesAsString(array $attributes): string * * @return list */ - private function orderGroups(array $groups): array + private function orderGroups(array $groups, Configuration $config): array { - if (null === $this->request) { - return $groups; - } + $declared = $config->context[ConfigurationFactory::CONTEXT_GROUPS] ?? []; - return array_values([...array_intersect($this->request->groups, $groups), ...array_diff($groups, $this->request->groups)]); + return array_values([...array_intersect($declared, $groups), ...array_diff($groups, $declared)]); } } diff --git a/src/JsonSchema/Generator/ConfigurationFactory.php b/src/JsonSchema/Generator/ConfigurationFactory.php index de870cf700..890eb65c23 100644 --- a/src/JsonSchema/Generator/ConfigurationFactory.php +++ b/src/JsonSchema/Generator/ConfigurationFactory.php @@ -32,6 +32,16 @@ final class ConfigurationFactory { use ResourceMetadataTrait; + public const CONTEXT_SCHEMA_TYPE = 'api_platform.schema_type'; + public const CONTEXT_FORMAT = 'api_platform.format'; + public const CONTEXT_PARTIAL = 'api_platform.partial'; + public const CONTEXT_VERSION = 'api_platform.version'; + public const CONTEXT_PROPERTY_OPTIONS = 'api_platform.property_options'; + public const CONTEXT_GROUPS = 'api_platform.groups'; + public const CONTEXT_DESCRIPTION = 'api_platform.description'; + public const CONTEXT_DEPRECATED = 'api_platform.deprecated'; + public const CONTEXT_EXTERNAL_DOCS = 'api_platform.external_docs'; + private const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; public function __construct( @@ -45,8 +55,10 @@ public function __construct( /** * @param array|null $serializerContext + * + * @return array{Type, Configuration}|null */ - public function create(string $className, string $format, string $type, ?Operation $operation, ?array $serializerContext, string $version, bool $forceCollection = false): ?SchemaGenerationRequest + public function create(string $className, string $format, string $type, ?Operation $operation, ?array $serializerContext, string $version, bool $forceCollection = false): ?array { $operationWasProvided = null !== $operation; @@ -82,6 +94,14 @@ public function create(string $className, string $format, string $type, ?Operati $groups = array_values((array) ($serializerContext[AbstractNormalizer::GROUPS] ?? [])); + $propertyOptions = ['schema_type' => $type, 'enable_getter_setter_extraction' => true]; + if (null !== $validationGroups) { + $propertyOptions['validation_groups'] = \is_array($validationGroups) ? $validationGroups : [$validationGroups]; + } + if ($ignoredAttributes = $serializerContext['ignored_attributes'] ?? null) { + $propertyOptions['ignored_attributes'] = $ignoredAttributes; + } + $configuration = new Configuration( dialect: $this->createDialect($version), groups: $groups, @@ -92,32 +112,23 @@ public function create(string $className, string $format, string $type, ?Operati definitionName: $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null, definitionPrefix: $this->definitionPolicy->createRootPrefix($operation, $className, $inputOrOutputClass), format: $isJsonMergePatch ? 'merge-patch+json' : $format, + context: [ + self::CONTEXT_SCHEMA_TYPE => $type, + self::CONTEXT_FORMAT => $format, + self::CONTEXT_PARTIAL => $this->definitionPolicy->isPartialUpdate($operation, $type, $format), + self::CONTEXT_VERSION => $version, + self::CONTEXT_PROPERTY_OPTIONS => $propertyOptions, + self::CONTEXT_GROUPS => $groups, + self::CONTEXT_DESCRIPTION => $operation?->getDescription() ?: null, + self::CONTEXT_DEPRECATED => Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason() && ($operationWasProvided || !($serializerContext[SchemaFactoryInterface::FORCE_SUBSCHEMA] ?? false)), + self::CONTEXT_EXTERNAL_DOCS => $operation instanceof HttpOperation ? ($operation->getTypes()[0] ?? null) : null, + ], ); - $propertyOptions = ['schema_type' => $type, 'enable_getter_setter_extraction' => true]; - if (null !== $validationGroups) { - $propertyOptions['validation_groups'] = \is_array($validationGroups) ? $validationGroups : [$validationGroups]; - } - if ($ignoredAttributes = $serializerContext['ignored_attributes'] ?? null) { - $propertyOptions['ignored_attributes'] = $ignoredAttributes; - } - $objectType = Type::object($inputOrOutputClass); $isCollection = $forceCollection || (!\in_array($method, ['POST', 'QUERY'], true) && $operation instanceof CollectionOperationInterface); - return new SchemaGenerationRequest( - $isCollection ? Type::list($objectType) : $objectType, - $configuration, - $type, - $format, - $this->definitionPolicy->isPartialUpdate($operation, $type, $format), - $version, - $propertyOptions, - $operation?->getDescription() ?: null, - Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason() && ($operationWasProvided || !($serializerContext[SchemaFactoryInterface::FORCE_SUBSCHEMA] ?? false)), - $operation instanceof HttpOperation ? ($operation->getTypes()[0] ?? null) : null, - $groups, - ); + return [$isCollection ? Type::list($objectType) : $objectType, $configuration]; } private function createDialect(string $version): Dialect diff --git a/src/JsonSchema/Generator/GeneratorSchemaFactory.php b/src/JsonSchema/Generator/GeneratorSchemaFactory.php index 1f6884f3cb..d6e43434f3 100644 --- a/src/JsonSchema/Generator/GeneratorSchemaFactory.php +++ b/src/JsonSchema/Generator/GeneratorSchemaFactory.php @@ -44,24 +44,26 @@ public function buildSchema(string $className, string $format = 'json', string $ { $schema = $schema ? clone $schema : new Schema(); - if (null === $request = $this->configurationFactory->create($className, $format, $type, $operation, $serializerContext, $schema->getVersion(), $forceCollection)) { + if (null === $created = $this->configurationFactory->create($className, $format, $type, $operation, $serializerContext, $schema->getVersion(), $forceCollection)) { return $schema; } - $policy = $this->definitionPolicy->forRequest($request); - $accessor = new PropertyMetadataAccessor($this->propertyNameCollectionFactory, $this->propertyMetadataFactory, $request->propertyOptions); + [$rootType, $configuration] = $created; + $context = $configuration->context; + $version = $context[ConfigurationFactory::CONTEXT_VERSION]; + $accessor = new PropertyMetadataAccessor($this->propertyNameCollectionFactory, $this->propertyMetadataFactory, $context[ConfigurationFactory::CONTEXT_PROPERTY_OPTIONS]); $generator = new SchemaGenerator( new ApiPlatformPropertyInfoExtractor($accessor), - $policy, + $this->definitionPolicy, [new ResourceIriClassSchemaResolver($accessor, $this->resourceClassResolver, $type), new NativeClassSchemaResolver()], - [new ApiPropertySchemaProvider($accessor, $request->version)], - [new ApiPropertySchemaEnricher($accessor, $this->resourceClassResolver, $request->version)], - [new BuiltinTypeDefinitionProcessor($accessor, $this->nameConverter), new PartialUpdateDefinitionProcessor($policy), new NestedOperationDefinitionProcessor($policy), ...$this->definitionProcessors], + [new ApiPropertySchemaProvider($accessor, $version)], + [new ApiPropertySchemaEnricher($accessor, $this->resourceClassResolver, $version)], + [new BuiltinTypeDefinitionProcessor($accessor, $this->nameConverter), new PartialUpdateDefinitionProcessor($this->definitionPolicy), new NestedOperationDefinitionProcessor($this->definitionPolicy), ...$this->definitionProcessors], $this->nameConverter, ); - $generated = $generator->generate($request->type, $request->configuration); - $refPath = $request->configuration->dialect->refPath; + $generated = $generator->generate($rootType, $configuration); + $refPath = $configuration->dialect->refPath; $root = $generated->getRoot(); $rootName = $root['items']['$ref'] ?? $root['$ref'] ?? null; @@ -75,9 +77,9 @@ public function buildSchema(string $className, string $format = 'json', string $ foreach ($generated->getDefinitions() as $name => $definition) { if ($name === $rootName) { $definition += array_filter([ - 'description' => $request->description, - 'deprecated' => $request->deprecated ?: null, - 'externalDocs' => $request->externalDocs ? ['url' => $request->externalDocs] : null, + 'description' => $context[ConfigurationFactory::CONTEXT_DESCRIPTION], + 'deprecated' => $context[ConfigurationFactory::CONTEXT_DEPRECATED] ?: null, + 'externalDocs' => $context[ConfigurationFactory::CONTEXT_EXTERNAL_DOCS] ? ['url' => $context[ConfigurationFactory::CONTEXT_EXTERNAL_DOCS]] : null, ]); } diff --git a/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php b/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php index 04e5ede557..b8cfcd582d 100644 --- a/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php +++ b/src/JsonSchema/Generator/NestedOperationDefinitionProcessor.php @@ -27,7 +27,7 @@ public function __construct(private readonly ApiPlatformDefinitionPolicy $defini public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array { - if (null === $parent || null === $operation = $this->definitionPolicy->findNestedOperation($class)) { + if (null === $parent || null === $operation = $this->definitionPolicy->findNestedOperation($class, $config)) { return $definition; } diff --git a/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php b/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php index 7f0451b786..282ab27488 100644 --- a/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php +++ b/src/JsonSchema/Generator/PartialUpdateDefinitionProcessor.php @@ -26,7 +26,7 @@ public function __construct(private readonly ApiPlatformDefinitionPolicy $defini public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array { - if ($this->definitionPolicy->isPartial($class, $parent)) { + if ($this->definitionPolicy->isPartial($class, $config, $parent)) { unset($definition['required']); } diff --git a/src/JsonSchema/Generator/SchemaGenerationRequest.php b/src/JsonSchema/Generator/SchemaGenerationRequest.php deleted file mode 100644 index 7e86acd0ab..0000000000 --- a/src/JsonSchema/Generator/SchemaGenerationRequest.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * 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\Generator; - -use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\TypeInfo\Type; - -final readonly class SchemaGenerationRequest -{ - /** - * @param array $propertyOptions - * @param list $groups - */ - public function __construct( - public Type $type, - public Configuration $configuration, - public string $schemaType, - public string $format, - public bool $partial, - public string $version, - public array $propertyOptions, - public ?string $description = null, - public bool $deprecated = false, - public ?string $externalDocs = null, - public array $groups = [], - ) { - } -} From b7add602f0f887bd6267911bb15b546b63101db8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2026 13:00:08 +0200 Subject: [PATCH 10/13] refactor(jsonschema): build the generator once --- .../Generator/ApiPropertySchemaEnricher.php | 3 +- .../Generator/ApiPropertySchemaProvider.php | 3 +- .../Generator/ConfigurationFactory.php | 12 ++++++ .../Generator/GeneratorSchemaFactory.php | 37 ++++++++++--------- .../Generator/PropertyMetadataAccessor.php | 31 ++++++++++------ .../ResourceIriClassSchemaResolver.php | 7 ++-- 6 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php index 7b4788551d..dec13eb898 100644 --- a/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php +++ b/src/JsonSchema/Generator/ApiPropertySchemaEnricher.php @@ -32,7 +32,6 @@ final class ApiPropertySchemaEnricher implements PropertySchemaEnricherInterface public function __construct( private readonly PropertyMetadataAccessor $accessor, private readonly ResourceClassResolverInterface $resourceClassResolver, - private readonly string $version, ) { } @@ -45,7 +44,7 @@ public function enrich(PropertySchema $property, Configuration $config, SchemaDo return $property->withRequired($required); } - $contextSchema = Schema::VERSION_JSON_SCHEMA === $this->version ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); + $contextSchema = Schema::VERSION_JSON_SCHEMA === $config->context[ConfigurationFactory::CONTEXT_VERSION] ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); $metadataSchema = $metadata->getSchema() ?? []; $schema = [...array_diff_key($property->schema, ['default' => true]), ...array_diff_key($metadataSchema, array_flip(self::OWNED_KEYWORDS)), ...$this->getDocumentation($metadata)]; $schema = array_replace(array_intersect_key($metadataSchema, $schema), $schema); diff --git a/src/JsonSchema/Generator/ApiPropertySchemaProvider.php b/src/JsonSchema/Generator/ApiPropertySchemaProvider.php index 091c33257e..2dcf20ca33 100644 --- a/src/JsonSchema/Generator/ApiPropertySchemaProvider.php +++ b/src/JsonSchema/Generator/ApiPropertySchemaProvider.php @@ -23,7 +23,6 @@ final class ApiPropertySchemaProvider implements PropertySchemaProviderInterface { public function __construct( private readonly PropertyMetadataAccessor $accessor, - private readonly string $version, ) { } @@ -35,7 +34,7 @@ public function provide(string $class, string $property, Configuration $config, return null; } - $contextSchema = Schema::VERSION_JSON_SCHEMA === $this->version ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); + $contextSchema = Schema::VERSION_JSON_SCHEMA === $config->context[ConfigurationFactory::CONTEXT_VERSION] ? $metadata->getJsonSchemaContext() : $metadata->getOpenapiContext(); return array_merge($metadata->getSchema() ?? [], $contextSchema ?? []); } diff --git a/src/JsonSchema/Generator/ConfigurationFactory.php b/src/JsonSchema/Generator/ConfigurationFactory.php index 890eb65c23..f82c8a5d8e 100644 --- a/src/JsonSchema/Generator/ConfigurationFactory.php +++ b/src/JsonSchema/Generator/ConfigurationFactory.php @@ -42,6 +42,18 @@ final class ConfigurationFactory public const CONTEXT_DEPRECATED = 'api_platform.deprecated'; public const CONTEXT_EXTERNAL_DOCS = 'api_platform.external_docs'; + public const CONTEXT_KEYS = [ + self::CONTEXT_SCHEMA_TYPE, + self::CONTEXT_FORMAT, + self::CONTEXT_PARTIAL, + self::CONTEXT_VERSION, + self::CONTEXT_PROPERTY_OPTIONS, + self::CONTEXT_GROUPS, + self::CONTEXT_DESCRIPTION, + self::CONTEXT_DEPRECATED, + self::CONTEXT_EXTERNAL_DOCS, + ]; + private const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; public function __construct( diff --git a/src/JsonSchema/Generator/GeneratorSchemaFactory.php b/src/JsonSchema/Generator/GeneratorSchemaFactory.php index d6e43434f3..b8a846d42c 100644 --- a/src/JsonSchema/Generator/GeneratorSchemaFactory.php +++ b/src/JsonSchema/Generator/GeneratorSchemaFactory.php @@ -26,18 +26,30 @@ final class GeneratorSchemaFactory implements SchemaFactoryInterface { + private readonly SchemaGenerator $generator; + /** * @param iterable $definitionProcessors */ public function __construct( private readonly ConfigurationFactory $configurationFactory, - private readonly ApiPlatformDefinitionPolicy $definitionPolicy, - private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, - private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, - private readonly ResourceClassResolverInterface $resourceClassResolver, - private readonly ?NameConverterInterface $nameConverter = null, - private readonly iterable $definitionProcessors = [], + ApiPlatformDefinitionPolicy $definitionPolicy, + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + ResourceClassResolverInterface $resourceClassResolver, + ?NameConverterInterface $nameConverter = null, + iterable $definitionProcessors = [], ) { + $accessor = new PropertyMetadataAccessor($propertyNameCollectionFactory, $propertyMetadataFactory); + $this->generator = new SchemaGenerator( + new ApiPlatformPropertyInfoExtractor($accessor), + $definitionPolicy, + [new ResourceIriClassSchemaResolver($accessor, $resourceClassResolver), new NativeClassSchemaResolver()], + [new ApiPropertySchemaProvider($accessor)], + [new ApiPropertySchemaEnricher($accessor, $resourceClassResolver)], + [new BuiltinTypeDefinitionProcessor($accessor, $nameConverter), new PartialUpdateDefinitionProcessor($definitionPolicy), new NestedOperationDefinitionProcessor($definitionPolicy), ...$definitionProcessors], + $nameConverter, + ); } public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema @@ -50,19 +62,8 @@ public function buildSchema(string $className, string $format = 'json', string $ [$rootType, $configuration] = $created; $context = $configuration->context; - $version = $context[ConfigurationFactory::CONTEXT_VERSION]; - $accessor = new PropertyMetadataAccessor($this->propertyNameCollectionFactory, $this->propertyMetadataFactory, $context[ConfigurationFactory::CONTEXT_PROPERTY_OPTIONS]); - $generator = new SchemaGenerator( - new ApiPlatformPropertyInfoExtractor($accessor), - $this->definitionPolicy, - [new ResourceIriClassSchemaResolver($accessor, $this->resourceClassResolver, $type), new NativeClassSchemaResolver()], - [new ApiPropertySchemaProvider($accessor, $version)], - [new ApiPropertySchemaEnricher($accessor, $this->resourceClassResolver, $version)], - [new BuiltinTypeDefinitionProcessor($accessor, $this->nameConverter), new PartialUpdateDefinitionProcessor($this->definitionPolicy), new NestedOperationDefinitionProcessor($this->definitionPolicy), ...$this->definitionProcessors], - $this->nameConverter, - ); - $generated = $generator->generate($rootType, $configuration); + $generated = $this->generator->generate($rootType, $configuration); $refPath = $configuration->dialect->refPath; $root = $generated->getRoot(); diff --git a/src/JsonSchema/Generator/PropertyMetadataAccessor.php b/src/JsonSchema/Generator/PropertyMetadataAccessor.php index 6e64941661..80ac92652a 100644 --- a/src/JsonSchema/Generator/PropertyMetadataAccessor.php +++ b/src/JsonSchema/Generator/PropertyMetadataAccessor.php @@ -20,18 +20,9 @@ final class PropertyMetadataAccessor { - /** - * @var array - */ - private array $properties = []; - - /** - * @param array $baseOptions - */ public function __construct( private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, - private readonly array $baseOptions, ) { } @@ -40,7 +31,7 @@ public function __construct( */ public static function contextFromConfiguration(Configuration $configuration): array { - $context = []; + $context = [ConfigurationFactory::CONTEXT_PROPERTY_OPTIONS => $configuration->context[ConfigurationFactory::CONTEXT_PROPERTY_OPTIONS] ?? []]; if ($configuration->groups) { $context['serializer_groups'] = $configuration->groups; } @@ -59,7 +50,7 @@ public static function contextFromConfiguration(Configuration $configuration): a */ public function getNames(string $class, array $context): array { - return array_values(iterator_to_array($this->propertyNameCollectionFactory->create($class, $this->baseOptions + $context), false)); + return array_values(iterator_to_array($this->propertyNameCollectionFactory->create($class, self::options($context)), false)); } /** @@ -68,6 +59,22 @@ public function getNames(string $class, array $context): array */ public function get(string $class, string $property, array $context): ApiProperty { - return $this->properties[$class.'::'.$property.'::'.json_encode($context, \JSON_THROW_ON_ERROR)] ??= $this->propertyMetadataFactory->create($class, $property, $this->baseOptions + $context); + return $this->propertyMetadataFactory->create($class, $property, self::options($context)); + } + + /** + * @param array $context + * + * @return array + */ + private static function options(array $context): array + { + $options = $context[ConfigurationFactory::CONTEXT_PROPERTY_OPTIONS] ?? []; + $context = array_diff_key($context, array_flip(ConfigurationFactory::CONTEXT_KEYS)); + if (null === ($context['serializer_groups'] ?? null)) { + unset($context['serializer_groups']); + } + + return $options + $context; } } diff --git a/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php b/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php index e94bc73247..1045f0f6b5 100644 --- a/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php +++ b/src/JsonSchema/Generator/ResourceIriClassSchemaResolver.php @@ -28,7 +28,6 @@ final class ResourceIriClassSchemaResolver implements ClassSchemaResolverInterfa public function __construct( private readonly PropertyMetadataAccessor $accessor, private readonly ResourceClassResolverInterface $resourceClassResolver, - private readonly string $schemaType, ) { } @@ -40,16 +39,16 @@ public function resolve(string $class, Configuration $config, SchemaDocument $do $metadata = $this->accessor->get($parent->class, $parent->property, PropertyMetadataAccessor::contextFromConfiguration($parent->config)); - if ($this->isLink($parent->class, $metadata)) { + if ($this->isLink($parent->class, $metadata, $config->context[ConfigurationFactory::CONTEXT_SCHEMA_TYPE])) { return null; } return ['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/']; } - private function isLink(string $class, ApiProperty $metadata): bool + private function isLink(string $class, ApiProperty $metadata, string $schemaType): bool { - $isInput = Schema::TYPE_INPUT === $this->schemaType; + $isInput = Schema::TYPE_INPUT === $schemaType; $link = $isInput ? $metadata->isWritableLink() : ($metadata->isReadableLink() || false === $metadata->getGenId()); if (!$isInput && !$this->resourceClassResolver->isResourceClass($class) && !$metadata->getNativeType()?->isSatisfiedBy($this->isResourceType(...))) { From 9650e88566fc7b4ab73e8259674d953cfd53df12 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2026 14:17:52 +0200 Subject: [PATCH 11/13] feat(symfony): opt in json schema definition processors --- .../AsJsonSchemaDefinitionProcessor.php | 19 +++++++ .../ApiPlatformExtension.php | 13 ++++- .../ApiPlatformExtensionTest.php | 55 +++++++++++++++++-- .../InvalidDefinitionProcessor.php | 21 +++++++ .../OptedInDefinitionProcessor.php | 29 ++++++++++ .../PlainDefinitionProcessor.php | 27 +++++++++ 6 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 src/Metadata/AsJsonSchemaDefinitionProcessor.php create mode 100644 src/Symfony/Tests/Fixtures/JsonSchemaProcessor/InvalidDefinitionProcessor.php create mode 100644 src/Symfony/Tests/Fixtures/JsonSchemaProcessor/OptedInDefinitionProcessor.php create mode 100644 src/Symfony/Tests/Fixtures/JsonSchemaProcessor/PlainDefinitionProcessor.php diff --git a/src/Metadata/AsJsonSchemaDefinitionProcessor.php b/src/Metadata/AsJsonSchemaDefinitionProcessor.php new file mode 100644 index 0000000000..88a00ef922 --- /dev/null +++ b/src/Metadata/AsJsonSchemaDefinitionProcessor.php @@ -0,0 +1,19 @@ + + * + * 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\Metadata; + +#[\Attribute(\Attribute::TARGET_CLASS)] +class AsJsonSchemaDefinitionProcessor +{ +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 7bc0d1c937..78e558285b 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -33,6 +33,7 @@ use ApiPlatform\HttpCache\PurgerInterface; use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\AsJsonSchemaDefinitionProcessor; use ApiPlatform\Metadata\AsOperationMutator; use ApiPlatform\Metadata\AsResourceMutator; use ApiPlatform\Metadata\Delete; @@ -238,8 +239,16 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('api_platform.parameter_provider'); if (interface_exists(DefinitionProcessorInterface::class)) { - $container->registerForAutoconfiguration(DefinitionProcessorInterface::class) - ->addTag('api_platform.json_schema.definition_processor'); + $container->registerAttributeForAutoconfiguration( + AsJsonSchemaDefinitionProcessor::class, + static function (ChildDefinition $definition, AsJsonSchemaDefinitionProcessor $attribute, \ReflectionClass $reflector): void { // @phpstan-ignore-line + if (!is_a($reflector->name, DefinitionProcessorInterface::class, true)) { + throw new RuntimeException(\sprintf('JSON Schema definition processor "%s" should implement %s', $reflector->name, DefinitionProcessorInterface::class)); + } + + $definition->addTag('api_platform.json_schema.definition_processor'); + }, + ); } $container->registerAttributeForAutoconfiguration( diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 54fcb70ef2..ac85802112 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -18,6 +18,7 @@ use ApiPlatform\JsonSchema\Generator\ConfigurationFactory; use ApiPlatform\JsonSchema\Generator\GeneratorSchemaFactory; use ApiPlatform\JsonSchema\SchemaFactory; +use ApiPlatform\Metadata\AsJsonSchemaDefinitionProcessor; use ApiPlatform\Metadata\Exception\ExceptionInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\IdentifiersExtractorInterface; @@ -31,6 +32,9 @@ use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\Symfony\Action\NotFoundAction; use ApiPlatform\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; +use ApiPlatform\Symfony\Tests\Fixtures\JsonSchemaProcessor\InvalidDefinitionProcessor; +use ApiPlatform\Symfony\Tests\Fixtures\JsonSchemaProcessor\OptedInDefinitionProcessor; +use ApiPlatform\Symfony\Tests\Fixtures\JsonSchemaProcessor\PlainDefinitionProcessor; use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\ORM\OptimisticLockException; @@ -41,6 +45,7 @@ use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\RuntimeException; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; @@ -460,6 +465,7 @@ public function testMcpProviderChainIsNotSecuredWithoutSecurityBundle(): void public function testJsonSchemaGeneratorIsDisabledByDefault(): void { + $this->skipWithoutJsonSchemaComponent(); (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); $this->assertSame(SchemaFactory::class, $this->container->getDefinition('api_platform.json_schema.schema_factory')->getClass()); @@ -471,6 +477,7 @@ public function testJsonSchemaGeneratorIsDisabledByDefault(): void public function testJsonSchemaGeneratorReplacesTheBaseSchemaFactory(): void { + $this->skipWithoutJsonSchemaComponent(); $config = self::DEFAULT_CONFIG; $config['api_platform']['json_schema']['generator'] = true; (new ApiPlatformExtension())->load($config, $this->container); @@ -493,13 +500,53 @@ public function testJsonSchemaGeneratorReplacesTheBaseSchemaFactory(): void $this->assertSame('api_platform.json_schema.schema_factory', $this->container->getDefinition('api_platform.json_schema.backward_compatible_schema_factory')->getDecoratedService()[0]); } - public function testJsonSchemaDefinitionProcessorsAreAutoconfigured(): void + public function testJsonSchemaDefinitionProcessorsAreNotAutoconfiguredFromTheInterface(): void { + $this->skipWithoutJsonSchemaComponent(); (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); - $instanceof = $this->container->getAutoconfiguredInstanceof(); - $this->assertArrayHasKey(DefinitionProcessorInterface::class, $instanceof); - $this->assertArrayHasKey('api_platform.json_schema.definition_processor', $instanceof[DefinitionProcessorInterface::class]->getTags()); + $this->assertArrayNotHasKey(DefinitionProcessorInterface::class, $this->container->getAutoconfiguredInstanceof()); + } + + public function testJsonSchemaDefinitionProcessorAttributeAddsTheTag(): void + { + $this->skipWithoutJsonSchemaComponent(); + (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); + + $definition = new ChildDefinition(''); + $this->applyAttributeAutoconfiguration($definition, OptedInDefinitionProcessor::class); + $this->assertArrayHasKey('api_platform.json_schema.definition_processor', $definition->getTags()); + + $plain = new ChildDefinition(''); + $this->applyAttributeAutoconfiguration($plain, PlainDefinitionProcessor::class); + $this->assertSame([], $plain->getTags()); + } + + public function testJsonSchemaDefinitionProcessorAttributeRequiresTheInterface(): void + { + $this->skipWithoutJsonSchemaComponent(); + (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(\sprintf('should implement %s', DefinitionProcessorInterface::class)); + $this->applyAttributeAutoconfiguration(new ChildDefinition(''), InvalidDefinitionProcessor::class); + } + + private function skipWithoutJsonSchemaComponent(): void + { + if (!interface_exists(DefinitionProcessorInterface::class)) { + $this->markTestSkipped('symfony/json-schema is not installed.'); + } + } + + private function applyAttributeAutoconfiguration(ChildDefinition $definition, string $class): void + { + $reflector = new \ReflectionClass($class); + foreach ($reflector->getAttributes(AsJsonSchemaDefinitionProcessor::class) as $attribute) { + foreach ($this->container->getAttributeAutoconfigurators()[AsJsonSchemaDefinitionProcessor::class] ?? [] as $configurator) { + $configurator($definition, $attribute->newInstance(), $reflector); + } + } } public function testItRegistersMetadataConfiguration(): void diff --git a/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/InvalidDefinitionProcessor.php b/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/InvalidDefinitionProcessor.php new file mode 100644 index 0000000000..af51754f9a --- /dev/null +++ b/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/InvalidDefinitionProcessor.php @@ -0,0 +1,21 @@ + + * + * 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\Symfony\Tests\Fixtures\JsonSchemaProcessor; + +use ApiPlatform\Metadata\AsJsonSchemaDefinitionProcessor; + +#[AsJsonSchemaDefinitionProcessor] +final class InvalidDefinitionProcessor +{ +} diff --git a/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/OptedInDefinitionProcessor.php b/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/OptedInDefinitionProcessor.php new file mode 100644 index 0000000000..f3a06643df --- /dev/null +++ b/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/OptedInDefinitionProcessor.php @@ -0,0 +1,29 @@ + + * + * 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\Symfony\Tests\Fixtures\JsonSchemaProcessor; + +use ApiPlatform\Metadata\AsJsonSchemaDefinitionProcessor; +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; +use Symfony\Component\JsonSchema\SchemaDocument; + +#[AsJsonSchemaDefinitionProcessor] +final class OptedInDefinitionProcessor implements DefinitionProcessorInterface +{ + public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array + { + return $definition; + } +} diff --git a/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/PlainDefinitionProcessor.php b/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/PlainDefinitionProcessor.php new file mode 100644 index 0000000000..40350b6e63 --- /dev/null +++ b/src/Symfony/Tests/Fixtures/JsonSchemaProcessor/PlainDefinitionProcessor.php @@ -0,0 +1,27 @@ + + * + * 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\Symfony\Tests\Fixtures\JsonSchemaProcessor; + +use Symfony\Component\JsonSchema\Configuration; +use Symfony\Component\JsonSchema\DefinitionParent; +use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; +use Symfony\Component\JsonSchema\SchemaDocument; + +final class PlainDefinitionProcessor implements DefinitionProcessorInterface +{ + public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array + { + return $definition; + } +} From 42e3dabe2c3a802a3c0a7ce0e40affa20540f09f Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2026 14:17:52 +0200 Subject: [PATCH 12/13] ci: run the json schema generator against symfony/json-schema --- .github/workflows/ci.yml | 52 +++++++++++++++++++ composer.json | 1 + src/JsonSchema/composer.json | 3 ++ src/Symfony/composer.json | 1 + .../JsonSchema/GoldenSchemaTest.php | 5 ++ 5 files changed, 62 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c342796354..83dc36e11c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,7 @@ jobs: composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . composer require --dev doctrine/mongodb-odm-bundle + composer require --dev --no-interaction "symfony/json-schema:8.2.x-dev" - name: Cache PHPStan results uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: @@ -990,6 +991,57 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit + phpunit-json-schema-generator: + name: PHPUnit (PHP ${{ matrix.php }}) (Symfony JsonSchema generator) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.5' + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + tools: pecl, composer:2.9.8 + extensions: intl, bcmath, curl, openssl, mbstring + coverage: none + ini-values: memory_limit=-1 + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-json-schema-generator-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer-json-schema-generator- + - name: Update project dependencies + run: | + composer global require "soyuka/pmu:$PMU_VERSION" + composer global config allow-plugins.soyuka/pmu true --no-interaction + composer global link . + composer require --dev --no-interaction "symfony/json-schema:8.2.x-dev" + - name: Remove cache + run: rm -Rf tests/Fixtures/app/var/cache/* + - name: Run golden schema tests (generator disabled) + run: vendor/bin/phpunit tests/Functional/JsonSchema/GoldenSchemaTest.php + - name: Clear test app cache + run: rm -Rf tests/Fixtures/app/var/cache/test + - name: Run JSON Schema tests (generator enabled) + env: + JSON_SCHEMA_GENERATOR: '1' + run: | + vendor/bin/phpunit tests/Functional/JsonSchema/JsonSchemaTest.php + vendor/bin/phpunit tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php + vendor/bin/phpunit tests/Functional/OpenApiTest.php + - name: Run container extension tests + run: vendor/bin/phpunit src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php --filter JsonSchema + phpunit-symfony-lowest: name: PHPUnit (PHP ${{ matrix.php }}) (Symfony lowest) runs-on: ubuntu-latest diff --git a/composer.json b/composer.json index 2a203be68d..077d5388c7 100644 --- a/composer.json +++ b/composer.json @@ -210,6 +210,7 @@ "symfony/uid": "To support Symfony UUID/ULID identifiers.", "symfony/messenger": "To support messenger integration.", "symfony/web-profiler-bundle": "To use the data collector.", + "symfony/json-schema": "To generate JSON Schemas with the Symfony JsonSchema component (opt-in, api_platform.json_schema.generator).", "symfony/json-streamer": "To use the JSON Streamer component.", "webonyx/graphql-php": "To support GraphQL." }, diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 713b099039..574e79d202 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -36,6 +36,9 @@ "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2 || ^13.0" }, + "suggest": { + "symfony/json-schema": "To generate JSON Schemas with the Symfony JsonSchema component (opt-in, api_platform.json_schema.generator)." + }, "autoload": { "psr-4": { "ApiPlatform\\JsonSchema\\": "" diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index fa02ecace2..9f40661bfc 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -87,6 +87,7 @@ "symfony/security": "To use authorization features.", "symfony/twig-bundle": "To use the Swagger UI integration.", "symfony/uid": "To support Symfony UUID/ULID identifiers.", + "symfony/json-schema": "To generate JSON Schemas with the Symfony JsonSchema component (opt-in, api_platform.json_schema.generator).", "symfony/messenger": "To support messenger integration and asynchronous Mercure updates.", "symfony/mercure-bundle": "To support mercure integration.", "symfony/web-profiler-bundle": "To use the data collector." diff --git a/tests/Functional/JsonSchema/GoldenSchemaTest.php b/tests/Functional/JsonSchema/GoldenSchemaTest.php index 93e61ca2a2..a1fbc85e10 100644 --- a/tests/Functional/JsonSchema/GoldenSchemaTest.php +++ b/tests/Functional/JsonSchema/GoldenSchemaTest.php @@ -55,6 +55,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\McpBook; use ApiPlatform\Tests\SetupClassResourcesTrait; use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\JsonSchema\SchemaGenerator; class GoldenSchemaTest extends ApiTestCase { @@ -152,6 +153,10 @@ public function testBuildSchemaMatchesGolden(string $case, string $className, st #[DataProvider('provideCases')] public function testGenerateMatchesGolden(string $case, string $className, string $format, string $type, ?string $operationName, ?array $serializerContext, string $version, array $configuration): void { + if (!class_exists(SchemaGenerator::class)) { + $this->markTestSkipped('symfony/json-schema is not installed.'); + } + if ('jsonld_nested_output_dto' === $case) { $this->markTestSkipped('The Hydra decorator re-resolves the nested operation by format, so jsonld describes the output DTO of an embedded resource while json does not: pending decision.'); } From 3842104d1c2adc86adf5dc2f441415e82bdcfc5a Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Oct 2026 14:21:15 +0200 Subject: [PATCH 13/13] fix(jsonschema): follow empty mixed schema and object schemas --- .../BuiltinTypeDefinitionProcessor.php | 79 ------------------- .../Generator/GeneratorSchemaFactory.php | 4 +- .../golden/output_builtin_type_quirks.json | 14 +--- 3 files changed, 4 insertions(+), 93 deletions(-) delete mode 100644 src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php diff --git a/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php b/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php deleted file mode 100644 index 0e571a2f01..0000000000 --- a/src/JsonSchema/Generator/BuiltinTypeDefinitionProcessor.php +++ /dev/null @@ -1,79 +0,0 @@ - - * - * 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\Generator; - -use Symfony\Component\JsonSchema\Configuration; -use Symfony\Component\JsonSchema\DefinitionParent; -use Symfony\Component\JsonSchema\DefinitionProcessor\DefinitionProcessorInterface; -use Symfony\Component\JsonSchema\NullSyntax; -use Symfony\Component\JsonSchema\SchemaDocument; -use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -use Symfony\Component\TypeInfo\Type\CollectionType; -use Symfony\Component\TypeInfo\Type\NullableType; -use Symfony\Component\TypeInfo\TypeIdentifier; - -final class BuiltinTypeDefinitionProcessor implements DefinitionProcessorInterface -{ - private const STRUCTURAL_KEYWORDS = ['type', 'items', 'additionalProperties', 'anyOf', 'allOf', 'oneOf', '$ref', 'enum', 'const']; - - public function __construct( - private readonly PropertyMetadataAccessor $accessor, - private readonly ?NameConverterInterface $nameConverter = null, - ) { - } - - public function process(array $definition, string $class, Configuration $config, SchemaDocument $document, ?DefinitionParent $parent): array - { - if (!isset($definition['properties'])) { - return $definition; - } - - $context = PropertyMetadataAccessor::contextFromConfiguration($config); - $nameConverterContext = array_filter(['groups' => $config->groups, 'attributes' => $config->attributes], static fn (?array $value): bool => null !== $value && [] !== $value); - $string = self::nullableString($config->dialect->nullSyntax); - - foreach ($this->accessor->getNames($class, $context) as $property) { - $name = $this->nameConverter?->normalize($property, $class, $config->format, $nameConverterContext) ?? $property; - if (!isset($definition['properties'][$name])) { - continue; - } - - $type = $this->accessor->get($class, $property, $context)->getNativeType(); - while ($type instanceof NullableType) { - $type = $type->getWrappedType(); - } - - $schema = $definition['properties'][$name]; - if ($type?->isIdentifiedBy(TypeIdentifier::MIXED) && !array_intersect_key($schema, array_flip(self::STRUCTURAL_KEYWORDS))) { - $definition['properties'][$name] = [...$string, ...$schema]; - } elseif ($type instanceof CollectionType && $type->getCollectionValueType()->isIdentifiedBy(TypeIdentifier::MIXED) && [] === ($schema['items'] ?? null)) { - $definition['properties'][$name]['items'] = $string; - } - } - - return $definition; - } - - /** - * @return array - */ - private static function nullableString(NullSyntax $syntax): array - { - return match ($syntax) { - NullSyntax::Union => ['type' => ['string', 'null']], - NullSyntax::NullableFlag => ['type' => 'string', 'nullable' => true], - NullSyntax::Unsupported => ['type' => 'string'], - }; - } -} diff --git a/src/JsonSchema/Generator/GeneratorSchemaFactory.php b/src/JsonSchema/Generator/GeneratorSchemaFactory.php index b8a846d42c..fadd353786 100644 --- a/src/JsonSchema/Generator/GeneratorSchemaFactory.php +++ b/src/JsonSchema/Generator/GeneratorSchemaFactory.php @@ -47,7 +47,7 @@ public function __construct( [new ResourceIriClassSchemaResolver($accessor, $resourceClassResolver), new NativeClassSchemaResolver()], [new ApiPropertySchemaProvider($accessor)], [new ApiPropertySchemaEnricher($accessor, $resourceClassResolver)], - [new BuiltinTypeDefinitionProcessor($accessor, $nameConverter), new PartialUpdateDefinitionProcessor($definitionPolicy), new NestedOperationDefinitionProcessor($definitionPolicy), ...$definitionProcessors], + [new PartialUpdateDefinitionProcessor($definitionPolicy), new NestedOperationDefinitionProcessor($definitionPolicy), ...$definitionProcessors], $nameConverter, ); } @@ -102,7 +102,7 @@ public function buildSchema(string $className, string $format = 'json', string $ private static function toDefinitionObject(array $definition): \ArrayObject { foreach ($definition['properties'] ?? [] as $property => $propertySchema) { - $definition['properties'][$property] = new \ArrayObject($propertySchema); + $definition['properties'][$property] = $propertySchema instanceof \ArrayObject ? $propertySchema : new \ArrayObject($propertySchema); } return new \ArrayObject($definition); diff --git a/tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json b/tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json index 7cf3e2f5a0..482a3cdba4 100644 --- a/tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json +++ b/tests/Functional/JsonSchema/golden/output_builtin_type_quirks.json @@ -9,12 +9,7 @@ "null" ] }, - "anything": { - "type": [ - "string", - "null" - ] - }, + "anything": [], "id": { "type": [ "integer", @@ -22,12 +17,7 @@ ] }, "untyped": { - "items": { - "type": [ - "string", - "null" - ] - }, + "items": [], "type": "array" } },