From 388a9baf0783d22b6210a779168f778b1c850808 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 23 May 2023 22:43:31 +0200 Subject: [PATCH 01/16] style: symfony rules has use_nullable_type_declaration to false --- SchemaFactory.php | 8 ++++---- SchemaFactoryInterface.php | 2 +- TypeFactory.php | 4 ++-- TypeFactoryInterface.php | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index 24d3b37..00188d1 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -61,7 +61,7 @@ public function addDistinctFormat(string $format): void /** * {@inheritdoc} */ - 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 + 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(); @@ -226,7 +226,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema; } - private function buildDefinitionName(string $className, string $format = 'json', ?string $inputOrOutputClass = null, Operation $operation = null, ?array $serializerContext = null): string + private function buildDefinitionName(string $className, string $format = 'json', string $inputOrOutputClass = null, Operation $operation = null, array $serializerContext = null): string { if ($operation) { $prefix = $operation->getShortName(); @@ -263,7 +263,7 @@ private function encodeDefinitionName(string $name): string return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name); } - private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?array $serializerContext = null): ?array + private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, Operation $operation = null, array $serializerContext = null): ?array { if (!$this->isResourceClass($className)) { return [ @@ -348,7 +348,7 @@ private function getValidationGroups(Operation $operation): array /** * Gets the options for the property name collection / property metadata factories. */ - private function getFactoryOptions(array $serializerContext, array $validationGroups, ?HttpOperation $operation = null): array + private function getFactoryOptions(array $serializerContext, array $validationGroups, HttpOperation $operation = null): array { $options = [ /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */ diff --git a/SchemaFactoryInterface.php b/SchemaFactoryInterface.php index ec99290..3248156 100644 --- a/SchemaFactoryInterface.php +++ b/SchemaFactoryInterface.php @@ -25,5 +25,5 @@ interface SchemaFactoryInterface /** * Builds the JSON Schema document corresponding to the given PHP class. */ - 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; + 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; } diff --git a/TypeFactory.php b/TypeFactory.php index 66d6cc3..05e695a 100644 --- a/TypeFactory.php +++ b/TypeFactory.php @@ -44,7 +44,7 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void /** * {@inheritdoc} */ - public function getType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array + public function getType(Type $type, string $format = 'json', bool $readableLink = null, array $serializerContext = null, Schema $schema = null): array { if ($type->isCollection()) { $keyType = $type->getCollectionKeyTypes()[0] ?? null; @@ -66,7 +66,7 @@ public function getType(Type $type, string $format = 'json', ?bool $readableLink return $this->addNullabilityToTypeDefinition($this->makeBasicType($type, $format, $readableLink, $serializerContext, $schema), $type, $schema); } - private function makeBasicType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array + private function makeBasicType(Type $type, string $format = 'json', bool $readableLink = null, array $serializerContext = null, Schema $schema = null): array { return match ($type->getBuiltinType()) { Type::BUILTIN_TYPE_INT => ['type' => 'integer'], diff --git a/TypeFactoryInterface.php b/TypeFactoryInterface.php index b008920..795854d 100644 --- a/TypeFactoryInterface.php +++ b/TypeFactoryInterface.php @@ -25,5 +25,5 @@ interface TypeFactoryInterface /** * Gets the JSON Schema document which specifies the data type corresponding to the given PHP type, and recursively adds needed new schema to the current schema if provided. */ - public function getType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array; + public function getType(Type $type, string $format = 'json', bool $readableLink = null, array $serializerContext = null, Schema $schema = null): array; } From c412810d9db8c0909bbac5ddd64aaa99afef1dc4 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 6 Jul 2023 10:39:36 +0200 Subject: [PATCH 02/16] feat: union/intersect types (#5470) * fix(metadata): handle union/intersect types * review * try to move SchemaFactory onto SchemaPropertyMetadataFactory * complete property schema on SchemaFactory * Apply suggestions from code review Co-authored-by: Antoine Bluchet * fix: review * fix: cs * fix: phpunit * fix: cs * fix: tests about maker * fix: JsonSchema::SchemaFactory * fix: behat tests * fix deprec * tests --------- Co-authored-by: Antoine Bluchet --- .../Factory/SchemaPropertyMetadataFactory.php | 276 ++++++++++++++++++ SchemaFactory.php | 89 +++--- .../Fixtures/DummyResourceImplementation.php | 22 ++ Tests/Fixtures/DummyResourceInterface.php | 19 ++ .../NotAResourceWithUnionIntersectTypes.php | 45 +++ Tests/Fixtures/Serializable.php | 21 ++ Tests/SchemaFactoryTest.php | 194 +++++++----- 7 files changed, 537 insertions(+), 129 deletions(-) create mode 100644 Metadata/Property/Factory/SchemaPropertyMetadataFactory.php create mode 100644 Tests/Fixtures/DummyResourceImplementation.php create mode 100644 Tests/Fixtures/DummyResourceInterface.php create mode 100644 Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php create mode 100644 Tests/Fixtures/Serializable.php diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php new file mode 100644 index 0000000..cf1c294 --- /dev/null +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -0,0 +1,276 @@ + + * + * 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\Metadata\Property\Factory; + +use ApiPlatform\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; + +/** + * Build ApiProperty::schema. + */ +final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + use ResourceClassInfoTrait; + + public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null) + { + $this->resourceClassResolver = $resourceClassResolver; + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + if (null === $this->decorated) { + $propertyMetadata = new ApiProperty(); + } else { + try { + $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); + } catch (PropertyNotFoundException) { + $propertyMetadata = new ApiProperty(); + } + } + + $propertySchema = $propertyMetadata->getSchema() ?? []; + + if (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { + $propertySchema['readOnly'] = true; + } + + if (!\array_key_exists('writeOnly', $propertySchema) && false === $propertyMetadata->isReadable()) { + $propertySchema['writeOnly'] = true; + } + + if (!\array_key_exists('description', $propertySchema) && null !== ($description = $propertyMetadata->getDescription())) { + $propertySchema['description'] = $description; + } + + // see https://github.com/json-schema-org/json-schema-spec/pull/737 + if (!\array_key_exists('deprecated', $propertySchema) && null !== $propertyMetadata->getDeprecationReason()) { + $propertySchema['deprecated'] = true; + } + + // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it + // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4 + if (!\array_key_exists('externalDocs', $propertySchema) && null !== ($iri = $propertyMetadata->getTypes()[0] ?? null)) { + $propertySchema['externalDocs'] = ['url' => $iri]; + } + + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) { + if ($default instanceof \BackedEnum) { + $default = $default->value; + } + $propertySchema['default'] = $default; + } + + if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) { + $propertySchema['example'] = $example; + } + + if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) { + $propertySchema['example'] = $propertySchema['default']; + } + + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // never override the following keys if at least one is already set + if ([] === $types + || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + ) { + return $propertyMetadata->withSchema($propertySchema); + } + + $valueSchema = []; + foreach ($types as $type) { + if ($isCollection = $type->isCollection()) { + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $valueType = $type->getCollectionValueTypes()[0] ?? null; + } else { + $keyType = null; + $valueType = $type; + } + + if (null === $valueType) { + $builtinType = 'string'; + $className = null; + } else { + $builtinType = $valueType->getBuiltinType(); + $className = $valueType->getClassName(); + } + + if (!\array_key_exists('owl:maxCardinality', $propertySchema) + && !$isCollection + && null !== $className + && $this->resourceClassResolver->isResourceClass($className) + ) { + $propertySchema['owl:maxCardinality'] = 1; + } + + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink()); + if (!\in_array($propertyType, $valueSchema, true)) { + $valueSchema[] = $propertyType; + } + } + + // only one builtInType detected (should be "type" or "$ref") + if (1 === \count($valueSchema)) { + return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]); + } + + // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types + try { + $reflectionClass = new \ReflectionClass($resourceClass); + $reflectionProperty = $reflectionClass->getProperty($property); + $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf'; + } catch (\ReflectionException) { + // cannot detect types + $composition = 'anyOf'; + } + + return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]); + } + + private function getType(Type $type, bool $readableLink = null): array + { + if (!$type->isCollection()) { + return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type); + } + + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getType($subType, $readableLink), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getType($subType, $readableLink), + ], $type); + } + + private function typeToArray(Type $type, bool $readableLink = null): array + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_INT => ['type' => 'integer'], + Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink), + default => ['type' => 'string'], + }; + } + + /** + * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. + * + * Note: if the class is not part of exceptions listed above, any class is considered as a resource. + */ + private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array + { + if (null === $className) { + return ['type' => 'string']; + } + + if (is_a($className, \DateTimeInterface::class, true)) { + return [ + 'type' => 'string', + 'format' => 'date-time', + ]; + } + + if (is_a($className, \DateInterval::class, true)) { + return [ + 'type' => 'string', + 'format' => 'duration', + ]; + } + + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'uuid', + ]; + } + + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } + + if (is_a($className, \SplFileInfo::class, true)) { + return [ + 'type' => 'string', + 'format' => 'binary', + ]; + } + + if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) { + $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); + + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'int'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + if (true !== $readableLink && $this->isResourceClass($className)) { + return [ + 'type' => 'string', + 'format' => 'iri-reference', + ]; + } + + return ['type' => 'string']; + } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + { + if (!$type->isNullable()) { + return $jsonSchema; + } + + if (\array_key_exists('$ref', $jsonSchema)) { + return ['anyOf' => [$jsonSchema, 'type' => 'null']]; + } + + return [...$jsonSchema, ...[ + 'type' => \is_array($jsonSchema['type']) + ? array_merge($jsonSchema['type'], ['null']) + : [$jsonSchema['type'], 'null'], + ]]; + } +} diff --git a/SchemaFactory.php b/SchemaFactory.php index 00188d1..521880c 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -24,7 +24,6 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -42,8 +41,12 @@ final class SchemaFactory implements SchemaFactoryInterface public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(private readonly TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) { + if ($typeFactory) { + trigger_deprecation('api-platform/core', '3.2', sprintf('The "%s" is not needed anymore and will not be used anymore.', TypeFactoryInterface::class)); + } + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceClassResolver = $resourceClassResolver; } @@ -144,7 +147,6 @@ public function buildSchema(string $className, string $format = 'json', string $ private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format): void { $version = $schema->getVersion(); - $swagger = Schema::VERSION_SWAGGER === $version; if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); } else { @@ -156,74 +158,49 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $additionalPropertySchema ?? [] ); - if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { - $propertySchema['readOnly'] = true; - } - if (!$swagger && false === $propertyMetadata->isReadable()) { - $propertySchema['writeOnly'] = true; - } - if (null !== $description = $propertyMetadata->getDescription()) { - $propertySchema['description'] = $description; - } - - $deprecationReason = $propertyMetadata->getDeprecationReason(); - - // see https://github.com/json-schema-org/json-schema-spec/pull/737 - if (!$swagger && null !== $deprecationReason) { - $propertySchema['deprecated'] = true; - } - // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it - // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4 - $iri = $propertyMetadata->getTypes()[0] ?? null; - if (null !== $iri) { - $propertySchema['externalDocs'] = ['url' => $iri]; - } + $types = $propertyMetadata->getBuiltinTypes() ?? []; - // TODO: 3.0 support multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + // never override the following keys if at least one is already set + // or if property has no type(s) defined + // or if property schema is already fully defined (type=string + format || enum) + $propertySchemaType = $propertySchema['type'] ?? false; + if ([] === $types + || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) + || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) + ) { + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault()) && (null === $type?->getClassName() || !$this->isResourceClass($type->getClassName()))) { - if ($default instanceof \BackedEnum) { - $default = $default->value; - } - $propertySchema['default'] = $default; + return; } - if (!isset($propertySchema['example']) && !empty($example = $propertyMetadata->getExample())) { - $propertySchema['example'] = $example; - } + // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) + // complete property schema with resource reference ($ref) only if it's related to an object - if (!isset($propertySchema['example']) && isset($propertySchema['default'])) { - $propertySchema['example'] = $propertySchema['default']; - } + $version = $schema->getVersion(); + $subSchema = new Schema($version); + $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema - $valueSchema = []; - if (null !== $type) { - if ($isCollection = $type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; + foreach ($types as $type) { + if ($type->isCollection()) { $valueType = $type->getCollectionValueTypes()[0] ?? null; } else { - $keyType = null; $valueType = $type; } - if (null === $valueType) { - $builtinType = 'string'; - $className = null; - } else { - $builtinType = $valueType->getBuiltinType(); - $className = $valueType->getClassName(); + $className = $valueType?->getClassName(); + if (null === $className || !$this->isResourceClass($className)) { + continue; } - $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema); + $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; + // prevent "type" and "anyOf" conflict + unset($propertySchema['type']); + break; } - if (\array_key_exists('type', $propertySchema) && \array_key_exists('$ref', $valueSchema)) { - $propertySchema = new \ArrayObject($propertySchema); - } else { - $propertySchema = new \ArrayObject($propertySchema + $valueSchema); - } - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema; + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); } private function buildDefinitionName(string $className, string $format = 'json', string $inputOrOutputClass = null, Operation $operation = null, array $serializerContext = null): string diff --git a/Tests/Fixtures/DummyResourceImplementation.php b/Tests/Fixtures/DummyResourceImplementation.php new file mode 100644 index 0000000..79fa0c3 --- /dev/null +++ b/Tests/Fixtures/DummyResourceImplementation.php @@ -0,0 +1,22 @@ + + * + * 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\Tests\Fixtures; + +class DummyResourceImplementation implements DummyResourceInterface +{ + public function getSomething(): string + { + return 'What is the answer to the universe?'; + } +} diff --git a/Tests/Fixtures/DummyResourceInterface.php b/Tests/Fixtures/DummyResourceInterface.php new file mode 100644 index 0000000..b0601a8 --- /dev/null +++ b/Tests/Fixtures/DummyResourceInterface.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\JsonSchema\Tests\Fixtures; + +interface DummyResourceInterface +{ + public function getSomething(): string; +} diff --git a/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php new file mode 100644 index 0000000..1af958a --- /dev/null +++ b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.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\JsonSchema\Tests\Fixtures; + +/** + * This class is not mapped as an API resource. + * It intends to test union and intersect types. + * + * @author Vincent Chalamon + */ +class NotAResourceWithUnionIntersectTypes +{ + public function __construct( + private $ignoredProperty, + private string|int|float|null $unionType, + private Serializable&DummyResourceInterface $intersectType + ) { + } + + public function getIgnoredProperty() + { + return $this->ignoredProperty; + } + + public function getUnionType() + { + return $this->unionType; + } + + public function getIntersectType() + { + return $this->intersectType; + } +} diff --git a/Tests/Fixtures/Serializable.php b/Tests/Fixtures/Serializable.php new file mode 100644 index 0000000..028ac02 --- /dev/null +++ b/Tests/Fixtures/Serializable.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\JsonSchema\Tests\Fixtures; + +interface Serializable +{ + public function __serialize(): array; + + public function __unserialize(array $data); +} diff --git a/Tests/SchemaFactoryTest.php b/Tests/SchemaFactoryTest.php index 806db5d..4d6dbf4 100644 --- a/Tests/SchemaFactoryTest.php +++ b/Tests/SchemaFactoryTest.php @@ -16,9 +16,11 @@ use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\OverriddenOperationDummy; +use ApiPlatform\JsonSchema\Tests\Fixtures\DummyResourceInterface; use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\GenderTypeEnum; use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResource; -use ApiPlatform\JsonSchema\TypeFactoryInterface; +use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResourceWithUnionIntersectTypes; +use ApiPlatform\JsonSchema\Tests\Fixtures\Serializable; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Operations; @@ -41,40 +43,38 @@ class SchemaFactoryTest extends TestCase public function testBuildSchemaForNonResourceClass(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING) - ), Argument::cetera())->willReturn([ - 'type' => 'string', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_INT) - ), Argument::cetera())->willReturn([ - 'type' => 'integer', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - ]); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withReadable(true)->withDefault('default_bar')->withExample('example_bar')); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withReadable(true) + ->withDefault('default_bar') + ->withExample('example_bar') + ->withSchema(['type' => 'integer', 'default' => 'default_bar', 'example' => 'example_bar']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)]) + ->withReadable(true) + ->withDefault('male') + ->withSchema(['type' => 'object', 'default' => 'male', 'example' => 'male']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -109,22 +109,71 @@ public function testBuildSchemaForNonResourceClass(): void $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); } - public function testBuildSchemaWithSerializerGroups(): void + public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING) - ), Argument::cetera())->willReturn([ - 'type' => 'string', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - ]); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, Argument::cetera())->willReturn(new PropertyNameCollection(['ignoredProperty', 'unionType', 'intersectType'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'ignoredProperty', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true)]) + ->withReadable(true) + ->withSchema(['type' => ['string', 'null']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true), new Type(Type::BUILTIN_TYPE_INT, nullable: true), new Type(Type::BUILTIN_TYPE_FLOAT, nullable: true)]) + ->withReadable(true) + ->withSchema(['oneOf' => [ + ['type' => ['string', 'null']], + ['type' => ['integer', 'null']], + ]]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'intersectType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, class: Serializable::class), new Type(Type::BUILTIN_TYPE_OBJECT, class: DummyResourceInterface::class)]) + ->withReadable(true) + ->withSchema(['type' => 'object']) + ); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); + + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(NotAResourceWithUnionIntersectTypes::class))->getShortName(), $rootDefinitionKey); + // @noRector + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); + $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); + $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]); + $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); + + $this->assertArrayHasKey('ignoredProperty', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['ignoredProperty']); + $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['ignoredProperty']['type']); + + $this->assertArrayHasKey('unionType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('oneOf', $definitions[$rootDefinitionKey]['properties']['unionType']); + $this->assertCount(2, $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]); + $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]['type']); + $this->assertSame(['integer', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][1]['type']); + + $this->assertArrayHasKey('intersectType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['intersectType']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['intersectType']['type']); + } + + public function testBuildSchemaWithSerializerGroups(): void + { $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $operation = (new Put())->withName('put')->withNormalizationContext([ @@ -144,15 +193,31 @@ public function testBuildSchemaWithSerializerGroups(): void $propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]) + ->withReadable(true) + ->withDefault(GenderTypeEnum::MALE) + ->withSchema(['type' => 'object']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -180,46 +245,29 @@ public function testBuildSchemaWithSerializerGroups(): void public function testBuildSchemaForAssociativeArray(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING), - Argument::which('isCollection', true), - Argument::that(function (Type $type): bool { - $keyTypes = $type->getCollectionKeyTypes(); - - return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_INT === $keyTypes[0]->getBuiltinType(); - }) - ), Argument::cetera())->willReturn([ - 'type' => 'array', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING), - Argument::which('isCollection', true), - Argument::that(function (Type $type): bool { - $keyTypes = $type->getCollectionKeyTypes(); - - return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_STRING === $keyTypes[0]->getBuiltinType(); - }) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - 'additionalProperties' => Type::BUILTIN_TYPE_STRING, - ]); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true)); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'array', 'items' => ['string', 'int']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'object', 'additionalProperties' => 'string']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); From a36d7e9ed26336ec15c24973ded017baa21f985e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20H=C3=A9bert?= Date: Mon, 11 Sep 2023 15:08:27 +0200 Subject: [PATCH 03/16] feat(serializer): add ApiProperty::uriTemplate option (#5675) * feat(serializer): add ApiProperty::uriTemplate option This feature gives control over the operation used for *toOne and *toMany relations IRI generation. When defined, API Platform will use the operation declared on the related resource that matches the uriTemplate string. In addition, this will override the value returned to be the IRI string only, not an object in JSONLD formats. For HAL and JSON:API format, the IRI will be used in links properties, and in the objects embedded or in relationship properties. * fix: update for guide --- .../Property/Factory/SchemaPropertyMetadataFactory.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index cf1c294..ad80663 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -49,7 +49,7 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema = $propertyMetadata->getSchema() ?? []; - if (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { + if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) { $propertySchema['readOnly'] = true; } @@ -124,6 +124,11 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['owl:maxCardinality'] = 1; } + if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { + $keyType = null; + $isCollection = false; + } + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink()); if (!\in_array($propertyType, $valueSchema, true)) { $valueSchema[] = $propertyType; From 9b693781071ec31ce86bf6bb667fbd53dee1e6d9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 11 Sep 2023 16:36:57 +0200 Subject: [PATCH 04/16] fix(jsonschema): allow embed resources --- .../Factory/SchemaPropertyMetadataFactory.php | 9 ++--- Schema.php | 1 + SchemaFactory.php | 34 ++++++++++++++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index ad80663..44d1574 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -14,6 +14,7 @@ namespace ApiPlatform\JsonSchema\Metadata\Property\Factory; use ApiPlatform\Exception\PropertyNotFoundException; +use ApiPlatform\JsonSchema\Schema; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -47,6 +48,7 @@ public function create(string $resourceClass, string $property, array $options = } } + $link = (($options['schema_type'] ?? null) === Schema::TYPE_INPUT) ? $propertyMetadata->isWritableLink() : $propertyMetadata->isReadableLink(); $propertySchema = $propertyMetadata->getSchema() ?? []; if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) { @@ -89,8 +91,6 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['example'] = $propertySchema['default']; } - $types = $propertyMetadata->getBuiltinTypes() ?? []; - // never override the following keys if at least one is already set if ([] === $types || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) @@ -129,7 +129,7 @@ public function create(string $resourceClass, string $property, array $options = $isCollection = false; } - $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink()); + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); if (!\in_array($propertyType, $valueSchema, true)) { $valueSchema[] = $propertyType; } @@ -254,7 +254,8 @@ private function getClassType(?string $className, bool $nullable, ?bool $readabl ]; } - return ['type' => 'string']; + // TODO: add propertyNameCollectionFactory and recurse to find the underlying schema? Right now SchemaFactory does the job so we don't compute anything here. + return ['type' => Schema::UNKNOWN_TYPE]; } /** diff --git a/Schema.php b/Schema.php index 4531649..9d7aa40 100644 --- a/Schema.php +++ b/Schema.php @@ -30,6 +30,7 @@ final class Schema extends \ArrayObject public const VERSION_JSON_SCHEMA = 'json-schema'; public const VERSION_OPENAPI = 'openapi'; public const VERSION_SWAGGER = 'swagger'; + public const UNKNOWN_TYPE = 'unknown_type'; public function __construct(private readonly string $version = self::VERSION_JSON_SCHEMA) { diff --git a/SchemaFactory.php b/SchemaFactory.php index 521880c..946bcf7 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -126,7 +126,7 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['externalDocs'] = ['url' => $operation->getTypes()[0]]; } - $options = $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null); + $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null); foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options); if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) { @@ -164,10 +164,16 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str // or if property has no type(s) defined // or if property schema is already fully defined (type=string + format || enum) $propertySchemaType = $propertySchema['type'] ?? false; - if ([] === $types - || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) - || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) - || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) + + $isUnknown = 'array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null); + + if ( + !$isUnknown && ( + [] === $types + || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) + || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) + ) ) { $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); @@ -176,13 +182,13 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) // complete property schema with resource reference ($ref) only if it's related to an object - $version = $schema->getVersion(); $subSchema = new Schema($version); $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema foreach ($types as $type) { - if ($type->isCollection()) { + $isCollection = $type->isCollection(); + if ($isCollection) { $valueType = $type->getCollectionValueTypes()[0] ?? null; } else { $valueType = $type; @@ -194,8 +200,18 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); - $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; - // prevent "type" and "anyOf" conflict + if ($isCollection) { + $propertySchema['items']['$ref'] = $subSchema['$ref']; + unset($propertySchema['items']['type']); + break; + } + + if ($type->isNullable()) { + $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; + } else { + $propertySchema['$ref'] = $subSchema['$ref']; + } + unset($propertySchema['type']); break; } From b03141472b17840cd5eb29a8548ec70b79c2fd30 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 20 Sep 2023 13:57:41 +0200 Subject: [PATCH 05/16] fix(jsonschema): build non-resource class schema (#5842) --- SchemaFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index 946bcf7..831f433 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -195,7 +195,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $className = $valueType?->getClassName(); - if (null === $className || !$this->isResourceClass($className)) { + if (null === $className) { continue; } From f600049ab1d08973601d680333fe03c84afc59b5 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 29 Sep 2023 16:43:34 +0200 Subject: [PATCH 06/16] fix(jsonschema): do not override nor complete ApiProperty::schema user value (#5855) * fix(jsonschema): do not override nor complete ApiProperty::schema user value * chore(metadata): improve ExtractorPropertyMetadataFactory --- .../Property/Factory/SchemaPropertyMetadataFactory.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 44d1574..003f4cf 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -31,6 +31,8 @@ final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInte { use ResourceClassInfoTrait; + public const JSON_SCHEMA_USER_DEFINED = 'user_defined_schema'; + public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null) { $this->resourceClassResolver = $resourceClassResolver; @@ -48,6 +50,13 @@ public function create(string $resourceClass, string $property, array $options = } } + $extraProperties = $propertyMetadata->getExtraProperties() ?? []; + // see AttributePropertyMetadataFactory + if (true === ($extraProperties[self::JSON_SCHEMA_USER_DEFINED] ?? false)) { + // schema seems to have been declared by the user: do not override nor complete user value + return $propertyMetadata; + } + $link = (($options['schema_type'] ?? null) === Schema::TYPE_INPUT) ? $propertyMetadata->isWritableLink() : $propertyMetadata->isReadableLink(); $propertySchema = $propertyMetadata->getSchema() ?? []; From c426710f0cffc63cfe5488e6e76e74f659e0117d Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 4 Oct 2023 15:57:20 +0200 Subject: [PATCH 07/16] fix(jsonschema): do not override nor complete ApiProperty::schema user value (#5864) --- SchemaFactory.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/SchemaFactory.php b/SchemaFactory.php index 831f433..5ed3811 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonSchema; +use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Exception\OperationNotFoundException; @@ -158,6 +159,15 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $additionalPropertySchema ?? [] ); + $extraProperties = $propertyMetadata->getExtraProperties() ?? []; + // see AttributePropertyMetadataFactory + if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) { + // schema seems to have been declared by the user: do not override nor complete user value + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); + + return; + } + $types = $propertyMetadata->getBuiltinTypes() ?? []; // never override the following keys if at least one is already set From 665189d4a74d9467bd14048198b973444a3250b0 Mon Sep 17 00:00:00 2001 From: Romain Allanot <80783376+romainallanot@users.noreply.github.com> Date: Thu, 5 Oct 2023 17:07:09 +0200 Subject: [PATCH 08/16] fix(jsonschema): field with unknown_type (#5869) --- SchemaFactory.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index 5ed3811..179a7cb 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -175,7 +175,8 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str // or if property schema is already fully defined (type=string + format || enum) $propertySchemaType = $propertySchema['type'] ?? false; - $isUnknown = 'array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null); + $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType + || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)); if ( !$isUnknown && ( From d6cc48099d8caa7dd40526f2060a978b04c8ff10 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 23 Oct 2023 10:41:16 +0200 Subject: [PATCH 09/16] fix(jsonschema): restore type factory usage (#5897) fixes #5896 --- SchemaFactory.php | 10 ++++++++-- TypeFactory.php | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index 179a7cb..2874c9a 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -37,7 +37,7 @@ final class SchemaFactory implements SchemaFactoryInterface { use ResourceClassInfoTrait; private array $distinctFormats = []; - + private ?TypeFactoryInterface $typeFactory = null; // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; @@ -45,7 +45,7 @@ final class SchemaFactory implements SchemaFactoryInterface public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) { if ($typeFactory) { - trigger_deprecation('api-platform/core', '3.2', sprintf('The "%s" is not needed anymore and will not be used anymore.', TypeFactoryInterface::class)); + $this->typeFactory = $typeFactory; } $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -198,6 +198,12 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema foreach ($types as $type) { + // TODO: in 3.3 add trigger_deprecation() as type factories are not used anymore, we moved this logic to SchemaPropertyMetadataFactory so that it gets cached + if ($typeFromFactory = $this->typeFactory?->getType($type, 'jsonschema', $propertyMetadata->isReadableLink(), $serializerContext)) { + $propertySchema = $typeFromFactory; + break; + } + $isCollection = $type->isCollection(); if ($isCollection) { $valueType = $type->getCollectionValueTypes()[0] ?? null; diff --git a/TypeFactory.php b/TypeFactory.php index 05e695a..872737c 100644 --- a/TypeFactory.php +++ b/TypeFactory.php @@ -23,6 +23,8 @@ /** * {@inheritdoc} * + * @deprecated since 3.3 https://github.com/api-platform/core/pull/5470 + * * @author Kévin Dunglas */ final class TypeFactory implements TypeFactoryInterface @@ -46,6 +48,11 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void */ public function getType(Type $type, string $format = 'json', bool $readableLink = null, array $serializerContext = null, Schema $schema = null): array { + if ('jsonschema' === $format) { + return []; + } + + // TODO: OpenApiFactory uses this to compute filter types if ($type->isCollection()) { $keyType = $type->getCollectionKeyTypes()[0] ?? null; $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); From 1fbdd23b054e74724a01e547b51414a35c4b6ae1 Mon Sep 17 00:00:00 2001 From: Fr13nzzz <89589750+Fr13nzzz@users.noreply.github.com> Date: Fri, 24 Nov 2023 09:57:59 +0100 Subject: [PATCH 10/16] fix(jsonschema): child entity property schema generation (#5988) (#5989) Subrecource property schemes should be built with the same scheme type than their parent, otherwise this could lead to errors in the generated schemes. fixes #5988 Co-authored-by: Bastien Lutz --- SchemaFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index 2874c9a..bc29cc9 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -139,13 +139,13 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['required'][] = $normalizedPropertyName; } - $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format); + $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); } return $schema; } - private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format): void + private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void { $version = $schema->getVersion(); if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { @@ -216,7 +216,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str continue; } - $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $subSchema = $this->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); if ($isCollection) { $propertySchema['items']['$ref'] = $subSchema['$ref']; unset($propertySchema['items']['type']); From 54e0397486bc1465566d7b4feda8ac0cf2dcfaf5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Nov 2023 18:33:41 +0100 Subject: [PATCH 11/16] fix(jsonschema): indirect resource input schema (#6001) Fixes #5998 A resource embedded in another class can be writable without having a write operation (POST, PUT, PATCH). --- SchemaFactory.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index bc29cc9..e797c85 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -83,7 +83,8 @@ public function buildSchema(string $className, string $format = 'json', string $ $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET'; } - if (Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) { + // In case of FORCE_SUBSCHEMA an object can be writable through another class eventhough it has no POST operation + if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) { return $schema; } @@ -217,6 +218,10 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $subSchema = $this->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + if (!isset($subSchema['$ref'])) { + continue; + } + if ($isCollection) { $propertySchema['items']['$ref'] = $subSchema['$ref']; unset($propertySchema['items']['type']); From bf2e085083e50240a1791488f2be1f639b65beac Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 13 Dec 2023 15:17:18 +0100 Subject: [PATCH 12/16] ci: conflict sebastian/comparator (#6032) * ci: conflict sebastian/comparator * for lowest --- composer.json | 131 ++++++++++++++++++++++++++------------------------ 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/composer.json b/composer.json index 11f2c81..1959fcd 100644 --- a/composer.json +++ b/composer.json @@ -1,70 +1,73 @@ { - "name": "api-platform/json-schema", - "description": "Generate a JSON Schema from a PHP class", - "type": "library", - "keywords": [ - "REST", - "JSON", - "API", - "Json Schema", - "OpenAPI", - "Swagger" - ], - "homepage": "https://api-platform.com", - "license": "MIT", - "authors": [ - { - "name": "Kévin Dunglas", - "email": "kevin@dunglas.fr", - "homepage": "https://dunglas.fr" + "name": "api-platform/json-schema", + "description": "Generate a JSON Schema from a PHP class", + "type": "library", + "keywords": [ + "REST", + "JSON", + "API", + "Json Schema", + "OpenAPI", + "Swagger" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "symfony/console": "^6.2", + "symfony/property-info": "^6.1", + "symfony/serializer": "^6.1", + "symfony/uid": "^6.1" }, - { - "name": "API Platform Community", - "homepage": "https://api-platform.com/community/contributors" - } - ], - "require": { - "php": ">=8.1", - "api-platform/metadata": "*@dev || ^3.1", - "symfony/console": "^6.2", - "symfony/property-info": "^6.1", - "symfony/serializer": "^6.1", - "symfony/uid": "^6.1" - }, - "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1" - }, - "autoload": { - "psr-4": { - "ApiPlatform\\JsonSchema\\": "" + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "symfony/phpunit-bridge": "^6.1" }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "config": { - "preferred-install": { - "*": "dist" + "autoload": { + "psr-4": { + "ApiPlatform\\JsonSchema\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "conflict": { + "sebastian/comparator": ">=5.0" }, - "sort-packages": true, - "allow-plugins": { - "composer/package-versions-deprecated": true, - "phpstan/extension-installer": true - } - }, - "extra": { - "branch-alias": { - "dev-main": "3.2.x-dev" + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } }, - "symfony": { - "require": "^6.1" - } - }, - "repositories": [ - { - "type": "path", - "url": "../Metadata" - } - ] + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + } + ] } From 78c881e4e83bf1e38d9450f9adafa49a5abd1b39 Mon Sep 17 00:00:00 2001 From: Sarim Khan Date: Mon, 18 Dec 2023 19:40:21 +0600 Subject: [PATCH 13/16] fix(jsonschema): fix invalid "int" type to "integer" (#6049) * fix(jsonschema): fix invalid "int" type to "integer" Fixes: 6048 For int backed enum, api-platform generates "type":"int", which is invalid in jsonschema. It should be "integer" * test: enum schema as integer --------- Co-authored-by: soyuka --- .../Factory/SchemaPropertyMetadataFactory.php | 2 +- Tests/Fixtures/DummyWithEnum.php | 37 +++++++++++++++++++ Tests/Fixtures/Enum/IntEnumAsIdentifier.php | 20 ++++++++++ .../SchemaPropertyMetadataFactoryTest.php | 37 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 Tests/Fixtures/DummyWithEnum.php create mode 100644 Tests/Fixtures/Enum/IntEnumAsIdentifier.php create mode 100644 Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 003f4cf..62b80d8 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -244,7 +244,7 @@ private function getClassType(?string $className, bool $nullable, ?bool $readabl if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) { $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); - $type = \is_string($enumCases[0] ?? '') ? 'string' : 'int'; + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; if ($nullable) { $enumCases[] = null; diff --git a/Tests/Fixtures/DummyWithEnum.php b/Tests/Fixtures/DummyWithEnum.php new file mode 100644 index 0000000..70acff5 --- /dev/null +++ b/Tests/Fixtures/DummyWithEnum.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\Tests\Fixtures; + +use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\IntEnumAsIdentifier; +use ApiPlatform\Metadata\ApiResource; + +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +#[ApiResource] +class DummyWithEnum +{ + public $id; + + public function __construct( + public IntEnumAsIdentifier $intEnumAsIdentifier = IntEnumAsIdentifier::FOO, + ) { + } +} diff --git a/Tests/Fixtures/Enum/IntEnumAsIdentifier.php b/Tests/Fixtures/Enum/IntEnumAsIdentifier.php new file mode 100644 index 0000000..27195c8 --- /dev/null +++ b/Tests/Fixtures/Enum/IntEnumAsIdentifier.php @@ -0,0 +1,20 @@ + + * + * 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\Tests\Fixtures\Enum; + +enum IntEnumAsIdentifier: int +{ + case FOO = 1; + case BAR = 2; +} diff --git a/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php new file mode 100644 index 0000000..0335315 --- /dev/null +++ b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.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\Tests\Metadata\Property\Factory; + +use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; +use ApiPlatform\JsonSchema\Tests\Fixtures\DummyWithEnum; +use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\IntEnumAsIdentifier; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; + +class SchemaPropertyMetadataFactoryTest extends TestCase +{ + public function testEnum(): void + { + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty(builtinTypes: [new Type(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)]); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'intEnumAsIdentifier')->willReturn($apiProperty); + $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); + $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'intEnumAsIdentifier'); + $this->assertEquals(['type' => ['integer', 'null'], 'enum' => [1, 2, null]], $apiProperty->getSchema()); + } +} From 8f8662eb486f6e9f93b776ce7500ba5d25b114b1 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 19 Dec 2023 10:13:55 +0100 Subject: [PATCH 14/16] fix(jsonschema): keep format subschema generation (#6055) fixes #5950 --- SchemaFactory.php | 11 +++++++++-- SchemaFactoryAwareInterface.php | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 SchemaFactoryAwareInterface.php diff --git a/SchemaFactory.php b/SchemaFactory.php index 6d366ab..42af8dd 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -33,11 +33,12 @@ * * @author Kévin Dunglas */ -final class SchemaFactory implements SchemaFactoryInterface +final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceClassInfoTrait; private array $distinctFormats = []; private ?TypeFactoryInterface $typeFactory = null; + private ?SchemaFactoryInterface $schemaFactory = null; // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; @@ -217,7 +218,8 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str continue; } - $subSchema = $this->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $subSchemaFactory = $this->schemaFactory ?: $this; + $subSchema = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); if (!isset($subSchema['$ref'])) { continue; } @@ -399,4 +401,9 @@ private function getShortClassName(string $fullyQualifiedName): string return end($parts); } + + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void + { + $this->schemaFactory = $schemaFactory; + } } diff --git a/SchemaFactoryAwareInterface.php b/SchemaFactoryAwareInterface.php new file mode 100644 index 0000000..fd9338e --- /dev/null +++ b/SchemaFactoryAwareInterface.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\JsonSchema; + +interface SchemaFactoryAwareInterface +{ + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void; +} From 481b72845e080bead2ba1eaa80be766683f37392 Mon Sep 17 00:00:00 2001 From: llupa <41073314+llupa@users.noreply.github.com> Date: Tue, 16 Jan 2024 18:12:21 +0100 Subject: [PATCH 15/16] fix(jsonschema): keep integer and number properties draft 4 compliant (#6098) --- BackwardCompatibleSchemaFactory.php | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 BackwardCompatibleSchemaFactory.php diff --git a/BackwardCompatibleSchemaFactory.php b/BackwardCompatibleSchemaFactory.php new file mode 100644 index 0000000..35b4316 --- /dev/null +++ b/BackwardCompatibleSchemaFactory.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema; + +use ApiPlatform\Metadata\Operation; + +/** + * This factory decorates range integer and number properties to keep Draft 4 backward compatibility. + * + * @see https://github.com/api-platform/core/issues/6041 + * + * @internal + */ +final class BackwardCompatibleSchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface +{ + public const SCHEMA_DRAFT4_VERSION = 'draft_4'; + + public function __construct(private readonly SchemaFactoryInterface $decorated) + { + } + + /** + * {@inheritDoc} + */ + 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 = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + + if (!($serializerContext[self::SCHEMA_DRAFT4_VERSION] ?? false)) { + return $schema; + } + + foreach ($schema->getDefinitions() as $definition) { + foreach ($definition['properties'] ?? [] as $property) { + if (isset($property['type']) && \in_array($property['type'], ['integer', 'number'], true)) { + if (isset($property['exclusiveMinimum'])) { + $property['minimum'] = $property['exclusiveMinimum']; + $property['exclusiveMinimum'] = true; + } + if (isset($property['exclusiveMaximum'])) { + $property['maximum'] = $property['exclusiveMaximum']; + $property['exclusiveMaximum'] = true; + } + } + } + } + + return $schema; + } + + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void + { + if ($this->decorated instanceof SchemaFactoryAwareInterface) { + $this->decorated->setSchemaFactory($schemaFactory); + } + } +} From 771aeb2f45666e734c9129e3bb422ac12bbd5614 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jan 2024 19:55:30 +0100 Subject: [PATCH 16/16] chore: components dependencies (#6113) * chore: components dependencies * test --- .gitattributes | 2 ++ Tests/Fixtures/Enum/GamePlayMode.php | 2 +- composer.json | 12 ++++++------ 3 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ae3c2e1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/.gitignore export-ignore +/Tests export-ignore diff --git a/Tests/Fixtures/Enum/GamePlayMode.php b/Tests/Fixtures/Enum/GamePlayMode.php index fbd9e5a..feaf25b 100644 --- a/Tests/Fixtures/Enum/GamePlayMode.php +++ b/Tests/Fixtures/Enum/GamePlayMode.php @@ -13,11 +13,11 @@ namespace ApiPlatform\JsonSchema\Tests\Fixtures\Enum; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Tests\Fixtures\TestBundle\Metadata\Get; #[Get(description: 'Indicates whether this game is multi-player, co-op or single-player.', provider: self::class.'::getCase')] #[GetCollection(provider: self::class.'::getCases')] diff --git a/composer.json b/composer.json index afa7c7a..e022cdd 100644 --- a/composer.json +++ b/composer.json @@ -26,14 +26,14 @@ "require": { "php": ">=8.1", "api-platform/metadata": "*@dev || ^3.1", - "symfony/console": "^6.2", - "symfony/property-info": "^6.1", - "symfony/serializer": "^6.1", - "symfony/uid": "^6.1" + "symfony/console": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0", + "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1", + "symfony/phpunit-bridge": "^6.4 || ^7.0", "sebastian/comparator": "<5.0" }, "autoload": { @@ -59,7 +59,7 @@ "dev-main": "3.2.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.4" } }, "repositories": [