From 388a9baf0783d22b6210a779168f778b1c850808 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 23 May 2023 22:43:31 +0200 Subject: [PATCH 01/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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": [ From f4aca1bdff8d652d80d1e43864d93e5a9681cb2c Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 31 Jan 2024 18:12:46 +0100 Subject: [PATCH 17/73] fix(hydra): move owl:maxCardinality from JsonSchema to Hydra (#6136) --- .../Property/Factory/SchemaPropertyMetadataFactory.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index f906f25..f2713a5 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -125,14 +125,6 @@ public function create(string $resourceClass, string $property, array $options = $className = $valueType->getClassName(); } - if (!\array_key_exists('owl:maxCardinality', $propertySchema) - && !$isCollection - && null !== $className - && $this->resourceClassResolver->isResourceClass($className) - ) { - $propertySchema['owl:maxCardinality'] = 1; - } - if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { $keyType = null; $isCollection = false; From a1d881bf31e17f00ebc51fcd13cb63303761ef96 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 2 Feb 2024 22:45:18 +0100 Subject: [PATCH 18/73] chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --- BackwardCompatibleSchemaFactory.php | 2 +- .../Property/Factory/SchemaPropertyMetadataFactory.php | 4 ++-- SchemaFactory.php | 10 +++++----- SchemaFactoryInterface.php | 2 +- Tests/Fixtures/ApiResource/Dummy.php | 4 ++-- TypeFactory.php | 6 +++--- TypeFactoryInterface.php | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/BackwardCompatibleSchemaFactory.php b/BackwardCompatibleSchemaFactory.php index 35b4316..9228d81 100644 --- a/BackwardCompatibleSchemaFactory.php +++ b/BackwardCompatibleSchemaFactory.php @@ -33,7 +33,7 @@ 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 + 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); diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index f2713a5..087de10 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -154,7 +154,7 @@ public function create(string $resourceClass, string $property, array $options = return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]); } - private function getType(Type $type, bool $readableLink = null): array + private function getType(Type $type, ?bool $readableLink = null): array { if (!$type->isCollection()) { return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type); @@ -176,7 +176,7 @@ private function getType(Type $type, bool $readableLink = null): array ], $type); } - private function typeToArray(Type $type, bool $readableLink = null): array + private function typeToArray(Type $type, ?bool $readableLink = null): array { return match ($type->getBuiltinType()) { Type::BUILTIN_TYPE_INT => ['type' => 'integer'], diff --git a/SchemaFactory.php b/SchemaFactory.php index 42af8dd..a128a89 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -43,7 +43,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(?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) { $this->typeFactory = $typeFactory; @@ -66,7 +66,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(); @@ -243,7 +243,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $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 + private function buildDefinitionName(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, ?array $serializerContext = null): string { if ($operation) { $prefix = $operation->getShortName(); @@ -279,7 +279,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 [ @@ -368,7 +368,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 3248156..ec99290 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/Tests/Fixtures/ApiResource/Dummy.php b/Tests/Fixtures/ApiResource/Dummy.php index 919f849..ee45c52 100644 --- a/Tests/Fixtures/ApiResource/Dummy.php +++ b/Tests/Fixtures/ApiResource/Dummy.php @@ -147,12 +147,12 @@ public function getFoo(): ?array return $this->foo; } - public function setFoo(array $foo = null): void + public function setFoo(?array $foo = null): void { $this->foo = $foo; } - public function setDummyDate(\DateTime $dummyDate = null): void + public function setDummyDate(?\DateTime $dummyDate = null): void { $this->dummyDate = $dummyDate; } diff --git a/TypeFactory.php b/TypeFactory.php index 34f1f3f..3921913 100644 --- a/TypeFactory.php +++ b/TypeFactory.php @@ -33,7 +33,7 @@ final class TypeFactory implements TypeFactoryInterface private ?SchemaFactoryInterface $schemaFactory = null; - public function __construct(ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(?ResourceClassResolverInterface $resourceClassResolver = null) { $this->resourceClassResolver = $resourceClassResolver; } @@ -46,7 +46,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 ('jsonschema' === $format) { return []; @@ -73,7 +73,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 795854d..b2ba889 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 7d79558538499f7dbb7e88ab20180df3cc1f4aa8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 3 Feb 2024 13:33:43 +0100 Subject: [PATCH 19/73] Merge 3.2 (#6145) * fix(hydra): move owl:maxCardinality from JsonSchema to Hydra (#6136) * docs: changelog v3.2.13 * cs: missing strict type * docs: guide sf/apip version * test: security configuration * chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --------- Co-authored-by: Vincent <407859+vincentchalamon@users.noreply.github.com> --- BackwardCompatibleSchemaFactory.php | 2 +- .../Factory/SchemaPropertyMetadataFactory.php | 12 ++---------- SchemaFactory.php | 10 +++++----- SchemaFactoryInterface.php | 2 +- Tests/Fixtures/ApiResource/Dummy.php | 4 ++-- TypeFactory.php | 6 +++--- TypeFactoryInterface.php | 2 +- 7 files changed, 15 insertions(+), 23 deletions(-) diff --git a/BackwardCompatibleSchemaFactory.php b/BackwardCompatibleSchemaFactory.php index 35b4316..9228d81 100644 --- a/BackwardCompatibleSchemaFactory.php +++ b/BackwardCompatibleSchemaFactory.php @@ -33,7 +33,7 @@ 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 + 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); diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index f906f25..087de10 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -125,14 +125,6 @@ public function create(string $resourceClass, string $property, array $options = $className = $valueType->getClassName(); } - if (!\array_key_exists('owl:maxCardinality', $propertySchema) - && !$isCollection - && null !== $className - && $this->resourceClassResolver->isResourceClass($className) - ) { - $propertySchema['owl:maxCardinality'] = 1; - } - if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { $keyType = null; $isCollection = false; @@ -162,7 +154,7 @@ public function create(string $resourceClass, string $property, array $options = return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]); } - private function getType(Type $type, bool $readableLink = null): array + private function getType(Type $type, ?bool $readableLink = null): array { if (!$type->isCollection()) { return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type); @@ -184,7 +176,7 @@ private function getType(Type $type, bool $readableLink = null): array ], $type); } - private function typeToArray(Type $type, bool $readableLink = null): array + private function typeToArray(Type $type, ?bool $readableLink = null): array { return match ($type->getBuiltinType()) { Type::BUILTIN_TYPE_INT => ['type' => 'integer'], diff --git a/SchemaFactory.php b/SchemaFactory.php index 42af8dd..a128a89 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -43,7 +43,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(?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) { $this->typeFactory = $typeFactory; @@ -66,7 +66,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(); @@ -243,7 +243,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $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 + private function buildDefinitionName(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, ?array $serializerContext = null): string { if ($operation) { $prefix = $operation->getShortName(); @@ -279,7 +279,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 [ @@ -368,7 +368,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 3248156..ec99290 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/Tests/Fixtures/ApiResource/Dummy.php b/Tests/Fixtures/ApiResource/Dummy.php index 919f849..ee45c52 100644 --- a/Tests/Fixtures/ApiResource/Dummy.php +++ b/Tests/Fixtures/ApiResource/Dummy.php @@ -147,12 +147,12 @@ public function getFoo(): ?array return $this->foo; } - public function setFoo(array $foo = null): void + public function setFoo(?array $foo = null): void { $this->foo = $foo; } - public function setDummyDate(\DateTime $dummyDate = null): void + public function setDummyDate(?\DateTime $dummyDate = null): void { $this->dummyDate = $dummyDate; } diff --git a/TypeFactory.php b/TypeFactory.php index 34f1f3f..3921913 100644 --- a/TypeFactory.php +++ b/TypeFactory.php @@ -33,7 +33,7 @@ final class TypeFactory implements TypeFactoryInterface private ?SchemaFactoryInterface $schemaFactory = null; - public function __construct(ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(?ResourceClassResolverInterface $resourceClassResolver = null) { $this->resourceClassResolver = $resourceClassResolver; } @@ -46,7 +46,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 ('jsonschema' === $format) { return []; @@ -73,7 +73,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 795854d..b2ba889 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 05c6be12af5c14e766cff09e273055ca90570f70 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 21 Feb 2024 09:45:36 +0100 Subject: [PATCH 20/73] fix(jsonschema): multiple type support (draft4) (#6171) --- BackwardCompatibleSchemaFactory.php | 12 +- Tests/BackwardCompatibleSchemaFactoryTest.php | 106 ++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 Tests/BackwardCompatibleSchemaFactoryTest.php diff --git a/BackwardCompatibleSchemaFactory.php b/BackwardCompatibleSchemaFactory.php index 9228d81..678ea8f 100644 --- a/BackwardCompatibleSchemaFactory.php +++ b/BackwardCompatibleSchemaFactory.php @@ -43,7 +43,15 @@ public function buildSchema(string $className, string $format = 'json', string $ foreach ($schema->getDefinitions() as $definition) { foreach ($definition['properties'] ?? [] as $property) { - if (isset($property['type']) && \in_array($property['type'], ['integer', 'number'], true)) { + if (!isset($property['type'])) { + continue; + } + + foreach ((array) $property['type'] as $type) { + if ('integer' !== $type && 'number' !== $type) { + continue; + } + if (isset($property['exclusiveMinimum'])) { $property['minimum'] = $property['exclusiveMinimum']; $property['exclusiveMinimum'] = true; @@ -52,6 +60,8 @@ public function buildSchema(string $className, string $format = 'json', string $ $property['maximum'] = $property['exclusiveMaximum']; $property['exclusiveMaximum'] = true; } + + break; } } } diff --git a/Tests/BackwardCompatibleSchemaFactoryTest.php b/Tests/BackwardCompatibleSchemaFactoryTest.php new file mode 100644 index 0000000..363f16f --- /dev/null +++ b/Tests/BackwardCompatibleSchemaFactoryTest.php @@ -0,0 +1,106 @@ + + * + * 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; + +use ApiPlatform\JsonSchema\BackwardCompatibleSchemaFactory; +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use PHPUnit\Framework\TestCase; + +class BackwardCompatibleSchemaFactoryTest extends TestCase +{ + public function testWithSingleType(): void + { + $schema = new Schema(); + $schema->setDefinitions(new \ArrayObject([ + 'a' => new \ArrayObject([ + 'properties' => new \ArrayObject([ + 'foo' => new \ArrayObject(['type' => 'integer', 'exclusiveMinimum' => 0, 'exclusiveMaximum' => 1]), + ]), + ]), + ])); + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($schema); + $schemaFactory = new BackwardCompatibleSchemaFactory($schemaFactory); + $schema = $schemaFactory->buildSchema('a', serializerContext: [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true]); + $schema = $schema->getDefinitions()['a']; + + $this->assertTrue($schema['properties']['foo']['exclusiveMinimum']); + $this->assertTrue($schema['properties']['foo']['exclusiveMaximum']); + $this->assertEquals($schema['properties']['foo']['minimum'], 0); + $this->assertEquals($schema['properties']['foo']['maximum'], 1); + } + + public function testWithMultipleType(): void + { + $schema = new Schema(); + $schema->setDefinitions(new \ArrayObject([ + 'a' => new \ArrayObject([ + 'properties' => new \ArrayObject([ + 'foo' => new \ArrayObject(['type' => ['number', 'null'], 'exclusiveMinimum' => 0, 'exclusiveMaximum' => 1]), + ]), + ]), + ])); + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($schema); + $schemaFactory = new BackwardCompatibleSchemaFactory($schemaFactory); + $schema = $schemaFactory->buildSchema('a', serializerContext: [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true]); + $schema = $schema->getDefinitions()['a']; + + $this->assertTrue($schema['properties']['foo']['exclusiveMinimum']); + $this->assertTrue($schema['properties']['foo']['exclusiveMaximum']); + $this->assertEquals($schema['properties']['foo']['minimum'], 0); + $this->assertEquals($schema['properties']['foo']['maximum'], 1); + } + + public function testWithoutNumber(): void + { + $schema = new Schema(); + $schema->setDefinitions(new \ArrayObject([ + 'a' => new \ArrayObject([ + 'properties' => new \ArrayObject([ + 'foo' => new \ArrayObject(['type' => ['string', 'null'], 'exclusiveMinimum' => 0, 'exclusiveMaximum' => 1]), + ]), + ]), + ])); + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($schema); + $schemaFactory = new BackwardCompatibleSchemaFactory($schemaFactory); + $schema = $schemaFactory->buildSchema('a', serializerContext: [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true]); + $schema = $schema->getDefinitions()['a']; + + $this->assertEquals($schema['properties']['foo']['exclusiveMinimum'], 0); + $this->assertEquals($schema['properties']['foo']['exclusiveMaximum'], 1); + } + + public function testWithoutFlag(): void + { + $schema = new Schema(); + $schema->setDefinitions(new \ArrayObject([ + 'a' => new \ArrayObject([ + 'properties' => new \ArrayObject([ + 'foo' => new \ArrayObject(['type' => ['string', 'null'], 'exclusiveMinimum' => 0, 'exclusiveMaximum' => 1]), + ]), + ]), + ])); + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($schema); + $schemaFactory = new BackwardCompatibleSchemaFactory($schemaFactory); + $schema = $schemaFactory->buildSchema('a', serializerContext: [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => false]); + $schema = $schema->getDefinitions()['a']; + + $this->assertEquals($schema['properties']['foo']['exclusiveMinimum'], 0); + $this->assertEquals($schema['properties']['foo']['exclusiveMaximum'], 1); + } +} From 365f2a85790e9b9556e23ef3e4df66a38a61b6af Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 29 Feb 2024 18:53:32 +0100 Subject: [PATCH 21/73] fix: components split dependencies (#6186) --- composer.json | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index e022cdd..69133a1 100644 --- a/composer.json +++ b/composer.json @@ -56,16 +56,13 @@ }, "extra": { "branch-alias": { - "dev-main": "3.2.x-dev" + "dev-main": "3.3.x-dev" }, "symfony": { "require": "^6.4" } }, - "repositories": [ - { - "type": "path", - "url": "../Metadata" - } - ] + "scripts": { + "test": "./vendor/bin/phpunit" + } } From 24b972c16accb58aea8cd3c635fd1dcda677fbc1 Mon Sep 17 00:00:00 2001 From: Gwendolen Lynch Date: Tue, 19 Mar 2024 21:06:35 +0100 Subject: [PATCH 22/73] fix(jsonschema): don't skip remaining multiple union types (#6223) --- SchemaFactory.php | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index a128a89..bfbc077 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -196,10 +196,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 + $refs = []; + $isNullable = null; foreach ($types as $type) { + $subSchema = new Schema($version); + $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema + // 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; @@ -230,14 +233,20 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str break; } - if ($type->isNullable()) { - $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; - } else { - $propertySchema['$ref'] = $subSchema['$ref']; - } + $refs[] = ['$ref' => $subSchema['$ref']]; + $isNullable = $isNullable ?? $type->isNullable(); + } + if ($isNullable) { + $refs[] = ['type' => 'null']; + } + + if (($c = \count($refs)) > 1) { + $propertySchema['anyOf'] = $refs; + unset($propertySchema['type']); + } elseif (1 === $c) { + $propertySchema['$ref'] = $refs[0]['$ref']; unset($propertySchema['type']); - break; } $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); From 6ed0649899a9fcc90e5eb0fa5b99b62fd0861cb7 Mon Sep 17 00:00:00 2001 From: Gwendolen Lynch Date: Mon, 25 Mar 2024 19:19:28 +0100 Subject: [PATCH 23/73] fix(jsonschema): generation of non-LD+JSON distinct schema formats (#6236) * fix(jsonschema): generation of non-LD+JSON distinct schema formats * fix(jsonschema): correct _embedded property schema output generation * fix (jsonschema): Chain schema factory decorators * fix(jsonschema): embedded json schema --------- Co-authored-by: soyuka --- SchemaFactory.php | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index bfbc077..0b40d42 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -36,14 +36,13 @@ 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'; - public function __construct(?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, private readonly ?array $distinctFormats = null) { if ($typeFactory) { $this->typeFactory = $typeFactory; @@ -53,16 +52,6 @@ public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadata $this->resourceClassResolver = $resourceClassResolver; } - /** - * When added to the list, the given format will lead to the creation of a new definition. - * - * @internal - */ - public function addDistinctFormat(string $format): void - { - $this->distinctFormats[$format] = true; - } - /** * {@inheritdoc} */ @@ -267,7 +256,7 @@ private function buildDefinitionName(string $className, string $format = 'json', $prefix .= '.'.$shortName; } - if (isset($this->distinctFormats[$format])) { + if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) { // JSON is the default, and so isn't included in the definition name $prefix .= '.'.$format; } From 3eb42aacf3eead5e8596c18419c4255c66c65c43 Mon Sep 17 00:00:00 2001 From: Gwendolen Lynch Date: Fri, 5 Apr 2024 11:24:10 +0200 Subject: [PATCH 24/73] fix(jsonapi): add missing "included" schema parts (#6277) * fix(jsonapi): add missing "included" schema parts * fix(test): test correct format * chore(jsonschema): refactor definition name logic * remove useless comment * remove empty line * add on invalid --------- Co-authored-by: Antoine Bluchet --- DefinitionNameFactory.php | 64 ++++++++++++ DefinitionNameFactoryInterface.php | 33 +++++++ ResourceMetadataTrait.php | 104 ++++++++++++++++++++ SchemaFactory.php | 152 ++++------------------------- Tests/SchemaFactoryTest.php | 45 ++++++++- 5 files changed, 262 insertions(+), 136 deletions(-) create mode 100644 DefinitionNameFactory.php create mode 100644 DefinitionNameFactoryInterface.php create mode 100644 ResourceMetadataTrait.php diff --git a/DefinitionNameFactory.php b/DefinitionNameFactory.php new file mode 100644 index 0000000..4007e83 --- /dev/null +++ b/DefinitionNameFactory.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\JsonSchema; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; + +final class DefinitionNameFactory implements DefinitionNameFactoryInterface +{ + use ResourceClassInfoTrait; + + public function __construct(private ?array $distinctFormats) + { + } + + public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string + { + if ($operation) { + $prefix = $operation->getShortName(); + } + + if (!isset($prefix)) { + $prefix = (new \ReflectionClass($className))->getShortName(); + } + + if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { + $parts = explode('\\', $inputOrOutputClass); + $shortName = end($parts); + $prefix .= '.'.$shortName; + } + + if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) { + // JSON is the default, and so isn't included in the definition name + $prefix .= '.'.$format; + } + + $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null; + if ($definitionName) { + $name = sprintf('%s-%s', $prefix, $definitionName); + } else { + $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); + $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; + } + + return $this->encodeDefinitionName($name); + } + + private function encodeDefinitionName(string $name): string + { + return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name); + } +} diff --git a/DefinitionNameFactoryInterface.php b/DefinitionNameFactoryInterface.php new file mode 100644 index 0000000..26de6f1 --- /dev/null +++ b/DefinitionNameFactoryInterface.php @@ -0,0 +1,33 @@ + + * + * 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; + +/** + * Factory for creating definition names for resources in a JSON Schema document. + * + * @author Gwendolen Lynch + */ +interface DefinitionNameFactoryInterface +{ + /** + * Creates a resource definition name. + * + * @param class-string $className + * + * @return string the definition name + */ + public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string; +} diff --git a/ResourceMetadataTrait.php b/ResourceMetadataTrait.php new file mode 100644 index 0000000..fc36e71 --- /dev/null +++ b/ResourceMetadataTrait.php @@ -0,0 +1,104 @@ + + * + * 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\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; + +/** + * @internal + */ +trait ResourceMetadataTrait +{ + use ResourceClassInfoTrait; + + private function findOutputClass(string $className, string $type, Operation $operation, ?array $serializerContext): ?string + { + $inputOrOutput = ['class' => $className]; + $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput); + $forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false; + + return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); + } + + private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext): Operation + { + if (null === $operation) { + if (null === $this->resourceMetadataFactory) { + return new HttpOperation(); + } + $resourceMetadataCollection = $this->resourceMetadataFactory->create($className); + + try { + $operation = $resourceMetadataCollection->getOperation(); + } catch (OperationNotFoundException $e) { + $operation = new HttpOperation(); + } + $forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false; + if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) { + $operation = new HttpOperation(); + } + + return $this->findOperationForType($resourceMetadataCollection, $type, $operation); + } + + // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise + if ($this->resourceMetadataFactory && !$operation->getClass()) { + $resourceMetadataCollection = $this->resourceMetadataFactory->create($className); + + if ($operation->getName()) { + return $resourceMetadataCollection->getOperation($operation->getName()); + } + + return $this->findOperationForType($resourceMetadataCollection, $type, $operation); + } + + return $operation; + } + + private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation + { + // Find the operation and use the first one that matches criterias + foreach ($resourceMetadataCollection as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() ?? [] as $op) { + if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) { + $operation = $op; + break 2; + } + + if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) { + $operation = $op; + break 2; + } + } + } + + return $operation; + } + + private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array + { + return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []); + } + + private function getShortClassName(string $fullyQualifiedName): string + { + $parts = explode('\\', $fullyQualifiedName); + return end($parts); + } +} diff --git a/SchemaFactory.php b/SchemaFactory.php index 0b40d42..d6253f2 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -16,15 +16,12 @@ use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\CollectionOperationInterface; -use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -35,18 +32,21 @@ */ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { - use ResourceClassInfoTrait; + use ResourceMetadataTrait; 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'; - public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null) + public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { if ($typeFactory) { $this->typeFactory = $typeFactory; } + if (!$definitionNameFactory) { + $this->definitionNameFactory = new DefinitionNameFactory($this->distinctFormats); + } $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceClassResolver = $resourceClassResolver; @@ -59,21 +59,31 @@ public function buildSchema(string $className, string $format = 'json', string $ { $schema = $schema ? clone $schema : new Schema(); - if (null === $metadata = $this->getMetadata($className, $type, $operation, $serializerContext)) { - return $schema; + if (!$this->isResourceClass($className)) { + $operation = null; + $inputOrOutputClass = $className; + $serializerContext ??= []; + } else { + $operation = $this->findOperation($className, $type, $operation, $serializerContext); + $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); + $serializerContext ??= $this->getSerializerContext($operation, $type); } - [$operation, $serializerContext, $validationGroups, $inputOrOutputClass] = $metadata; + if (null === $inputOrOutputClass) { + // input or output disabled + return $schema; + } + $validationGroups = $operation ? $this->getValidationGroups($operation) : []; $version = $schema->getVersion(); - $definitionName = $this->buildDefinitionName($className, $format, $inputOrOutputClass, $operation, $serializerContext); + $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext); $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; if (!$operation) { $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET'; } - // In case of FORCE_SUBSCHEMA an object can be writable through another class eventhough it has no POST operation + // In case of FORCE_SUBSCHEMA an object can be writable through another class even though it has no POST operation if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) { return $schema; } @@ -241,121 +251,6 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $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 - { - if ($operation) { - $prefix = $operation->getShortName(); - } - - if (!isset($prefix)) { - $prefix = (new \ReflectionClass($className))->getShortName(); - } - - if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { - $shortName = $this->getShortClassName($inputOrOutputClass); - $prefix .= '.'.$shortName; - } - - if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) { - // JSON is the default, and so isn't included in the definition name - $prefix .= '.'.$format; - } - - $definitionName = $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null; - if ($definitionName) { - $name = sprintf('%s-%s', $prefix, $definitionName); - } else { - $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); - $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; - } - - return $this->encodeDefinitionName($name); - } - - 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 - { - if (!$this->isResourceClass($className)) { - return [ - null, - $serializerContext ?? [], - [], - $className, - ]; - } - - $forceSubschema = $serializerContext[self::FORCE_SUBSCHEMA] ?? false; - if (null === $operation) { - $resourceMetadataCollection = $this->resourceMetadataFactory->create($className); - try { - $operation = $resourceMetadataCollection->getOperation(); - } catch (OperationNotFoundException $e) { - $operation = new HttpOperation(); - } - if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) { - $operation = new HttpOperation(); - } - - $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation); - } else { - // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise - if (!$operation->getClass()) { - $resourceMetadataCollection = $this->resourceMetadataFactory->create($className); - - if ($operation->getName()) { - $operation = $resourceMetadataCollection->getOperation($operation->getName()); - } else { - $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation); - } - } - } - - $inputOrOutput = ['class' => $className]; - $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput); - $outputClass = $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); - - if (null === $outputClass) { - // input or output disabled - return null; - } - - return [ - $operation, - $serializerContext ?? $this->getSerializerContext($operation, $type), - $this->getValidationGroups($operation), - $outputClass, - ]; - } - - private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation - { - // Find the operation and use the first one that matches criterias - foreach ($resourceMetadataCollection as $resourceMetadata) { - foreach ($resourceMetadata->getOperations() ?? [] as $op) { - if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) { - $operation = $op; - break 2; - } - - if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) { - $operation = $op; - break 2; - } - } - } - - return $operation; - } - - private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array - { - return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []); - } - private function getValidationGroups(Operation $operation): array { $groups = $operation->getValidationContext()['groups'] ?? []; @@ -393,13 +288,6 @@ private function getFactoryOptions(array $serializerContext, array $validationGr return $options; } - private function getShortClassName(string $fullyQualifiedName): string - { - $parts = explode('\\', $fullyQualifiedName); - - return end($parts); - } - public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void { $this->schemaFactory = $schemaFactory; diff --git a/Tests/SchemaFactoryTest.php b/Tests/SchemaFactoryTest.php index 4d6dbf4..0f66e91 100644 --- a/Tests/SchemaFactoryTest.php +++ b/Tests/SchemaFactoryTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonSchema\Tests; +use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\OverriddenOperationDummy; @@ -74,7 +75,16 @@ public function testBuildSchemaForNonResourceClass(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + + $schemaFactory = new SchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -142,7 +152,16 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): voi $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); - $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + + $schemaFactory = new SchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -217,7 +236,16 @@ public function testBuildSchemaWithSerializerGroups(): void $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + + $schemaFactory = new SchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); $resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -267,7 +295,16 @@ public function testBuildSchemaForAssociativeArray(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + + $schemaFactory = new SchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); From 4b60b524d7bcda1e45379a10c0d466fe515d56be Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 5 Apr 2024 12:21:45 +0200 Subject: [PATCH 25/73] style: empty line --- ResourceMetadataTrait.php | 1 + 1 file changed, 1 insertion(+) diff --git a/ResourceMetadataTrait.php b/ResourceMetadataTrait.php index fc36e71..51232c3 100644 --- a/ResourceMetadataTrait.php +++ b/ResourceMetadataTrait.php @@ -99,6 +99,7 @@ private function getSerializerContext(Operation $operation, string $type = Schem private function getShortClassName(string $fullyQualifiedName): string { $parts = explode('\\', $fullyQualifiedName); + return end($parts); } } From 59283edb8d803cd71a454603401486b40bbc6f0a Mon Sep 17 00:00:00 2001 From: Gwendolen Lynch Date: Thu, 11 Apr 2024 09:31:58 +0200 Subject: [PATCH 26/73] fix(jsonschema): don't try to define $ref if set in attributes (#6303) --- SchemaFactory.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SchemaFactory.php b/SchemaFactory.php index 0b40d42..901660a 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -150,6 +150,11 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $additionalPropertySchema ?? [] ); + // @see https://github.com/api-platform/core/issues/6299 + if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) { + unset($propertySchema['type']); + } + $extraProperties = $propertyMetadata->getExtraProperties() ?? []; // see AttributePropertyMetadataFactory if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) { From 280df0ae9b20a8c9aa6f14974921202ff8c44584 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 25 Jun 2024 16:01:23 +0200 Subject: [PATCH 27/73] feat(laravel): laravel component (#5882) * feat(laravel): laravel component * try to skip laravel * feat(jsonapi): component * feat(laravel): json api support (needs review) * work on relations * relations (needs toMany) + skolem + IRI to resource * links handler * ulid * validation * slug post * remove deprecations * move classes * fix tests * fix tests metadata * phpstan * missing class * fix laravel tests * fix stan --- Metadata/Property/Factory/SchemaPropertyMetadataFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 087de10..eeeaa42 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -13,9 +13,9 @@ namespace ApiPlatform\JsonSchema\Metadata\Property\Factory; -use ApiPlatform\Exception\PropertyNotFoundException; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; From fffb5c52b8ac46c64541dab9f0c0c14dc1568e39 Mon Sep 17 00:00:00 2001 From: Benjamin ROTHAN Date: Thu, 27 Jun 2024 09:41:06 +0200 Subject: [PATCH 28/73] fix(metadata): wrong schema generated if openapicontext set on array (#6431) --- .../Factory/SchemaPropertyMetadataFactory.php | 3 +- .../DummyWithCustomOpenApiContext.php | 33 +++++++++++++++++++ .../SchemaPropertyMetadataFactoryTest.php | 15 +++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 Tests/Fixtures/DummyWithCustomOpenApiContext.php diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 087de10..d3b55e3 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -100,9 +100,10 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['example'] = $propertySchema['default']; } - // never override the following keys if at least one is already set + // never override the following keys if at least one is already set or if there's a custom openapi context if ([] === $types || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || ($propertyMetadata->getOpenapiContext() ?? false) ) { return $propertyMetadata->withSchema($propertySchema); } diff --git a/Tests/Fixtures/DummyWithCustomOpenApiContext.php b/Tests/Fixtures/DummyWithCustomOpenApiContext.php new file mode 100644 index 0000000..22517c6 --- /dev/null +++ b/Tests/Fixtures/DummyWithCustomOpenApiContext.php @@ -0,0 +1,33 @@ + + * + * 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\Metadata\ApiProperty; +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 DummyWithCustomOpenApiContext +{ + #[ApiProperty(openapiContext: ['type' => 'object', 'properties' => ['alpha' => ['type' => 'integer']]])] + public $acme; +} diff --git a/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php index 0335315..ec08170 100644 --- a/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php +++ b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\JsonSchema\Tests\Metadata\Property\Factory; use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; +use ApiPlatform\JsonSchema\Tests\Fixtures\DummyWithCustomOpenApiContext; use ApiPlatform\JsonSchema\Tests\Fixtures\DummyWithEnum; use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\IntEnumAsIdentifier; use ApiPlatform\Metadata\ApiProperty; @@ -34,4 +35,18 @@ public function testEnum(): void $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'intEnumAsIdentifier'); $this->assertEquals(['type' => ['integer', 'null'], 'enum' => [1, 2, null]], $apiProperty->getSchema()); } + + public function testWithCustomOpenApiContext(): void + { + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty( + builtinTypes: [new Type(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)], + openapiContext: ['type' => 'object', 'properties' => ['alpha' => ['type' => 'integer']]], + ); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'acme')->willReturn($apiProperty); + $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); + $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'acme'); + $this->assertEquals([], $apiProperty->getSchema()); + } } From 4ce0e79c777d7da98e16c5ad06c4c61762096d73 Mon Sep 17 00:00:00 2001 From: Takashi Kanemoto <4360663+ttskch@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:17:44 +0900 Subject: [PATCH 29/73] fix(jsonschema): make all required properties optional in PATCH operation with 'json' format (#6394) --- SchemaFactory.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index 097eadd..f9c7572 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -33,6 +34,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; + + private const PATCH_SCHEMA_POSTFIX = '.patch'; + 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 @@ -88,6 +92,12 @@ public function buildSchema(string $className, string $format = 'json', string $ return $schema; } + $isJsonMergePatch = 'json' === $format && $operation instanceof Patch && Schema::TYPE_INPUT === $type; + + if ($isJsonMergePatch) { + $definitionName .= self::PATCH_SCHEMA_POSTFIX; + } + if (!isset($schema['$ref']) && !isset($schema['type'])) { $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName; if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) { @@ -136,7 +146,7 @@ public function buildSchema(string $className, string $format = 'json', string $ } $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName; - if ($propertyMetadata->isRequired()) { + if ($propertyMetadata->isRequired() && !$isJsonMergePatch) { $definition['required'][] = $normalizedPropertyName; } From 76bfa87f7a48106b21c869f6468b77337aca8762 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 1 Jul 2024 13:14:52 +0200 Subject: [PATCH 30/73] fix(symfony): property info with doctrine collections --- Metadata/Property/Factory/SchemaPropertyMetadataFactory.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index d3b55e3..d5070f8 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use Doctrine\Common\Collections\ArrayCollection; use Ramsey\Uuid\UuidInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Uid\Ulid; @@ -110,6 +111,11 @@ public function create(string $resourceClass, string $property, array $options = $valueSchema = []; foreach ($types as $type) { + // Temp fix for https://github.com/symfony/symfony/pull/52699 + if (ArrayCollection::class === $type->getClassName()) { + $type = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); + } + if ($isCollection = $type->isCollection()) { $keyType = $type->getCollectionKeyTypes()[0] ?? null; $valueType = $type->getCollectionValueTypes()[0] ?? null; From acbc453577d1a6cb38192ae380ff55e9dfecd44b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 15 Jul 2024 11:45:37 +0200 Subject: [PATCH 31/73] chore: symfony 7.1 dependency and branch alias (#6468) * chore: symfony 7.1 dependency and branch alias * chore: main_request --- composer.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 69133a1..ac440fa 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,8 @@ "php": ">=8.1", "api-platform/metadata": "*@dev || ^3.1", "symfony/console": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.1", + "symfony/serializer": "^6.4 || ^7.1", "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { @@ -56,13 +56,14 @@ }, "extra": { "branch-alias": { - "dev-main": "3.3.x-dev" + "dev-main": "4.0.x-dev", + "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4" + "require": "^6.4 || ^7.1" } }, "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From 03b307002a871371715e27044af6e2d1a2cea8fa Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jul 2024 08:01:36 +0200 Subject: [PATCH 32/73] =?UTF-8?q?Revert=20"fix(jsonschema):=20make=20all?= =?UTF-8?q?=20required=20properties=20optional=20in=20PATCH=20opera?= =?UTF-8?q?=E2=80=A6"=20(#6476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 842091ddf5b41c7a0d76bfbcddccff2920a84c35. --- SchemaFactory.php | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index f9c7572..097eadd 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -34,9 +33,6 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; - - private const PATCH_SCHEMA_POSTFIX = '.patch'; - 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 @@ -92,12 +88,6 @@ public function buildSchema(string $className, string $format = 'json', string $ return $schema; } - $isJsonMergePatch = 'json' === $format && $operation instanceof Patch && Schema::TYPE_INPUT === $type; - - if ($isJsonMergePatch) { - $definitionName .= self::PATCH_SCHEMA_POSTFIX; - } - if (!isset($schema['$ref']) && !isset($schema['type'])) { $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName; if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) { @@ -146,7 +136,7 @@ public function buildSchema(string $className, string $format = 'json', string $ } $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName; - if ($propertyMetadata->isRequired() && !$isJsonMergePatch) { + if ($propertyMetadata->isRequired()) { $definition['required'][] = $normalizedPropertyName; } From 7a4f0552a3a16aaa8ea2ef08fbe29a9e3846c10d Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 25 Jun 2024 22:24:35 +0200 Subject: [PATCH 33/73] chore: remove deprecations --- SchemaFactory.php | 12 +- Tests/Fixtures/NotAResource.php | 10 +- Tests/SchemaFactoryTest.php | 4 - Tests/SchemaTest.php | 12 +- Tests/TypeFactoryTest.php | 472 -------------------------------- TypeFactory.php | 207 -------------- TypeFactoryInterface.php | 29 -- composer.json | 5 +- phpunit.xml.dist | 47 ++-- 9 files changed, 27 insertions(+), 771 deletions(-) delete mode 100644 Tests/TypeFactoryTest.php delete mode 100644 TypeFactory.php delete mode 100644 TypeFactoryInterface.php diff --git a/SchemaFactory.php b/SchemaFactory.php index f9c7572..3d3fcb3 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -37,17 +37,13 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI private const PATCH_SCHEMA_POSTFIX = '.patch'; - 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'; - public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { - if ($typeFactory) { - $this->typeFactory = $typeFactory; - } if (!$definitionNameFactory) { $this->definitionNameFactory = new DefinitionNameFactory($this->distinctFormats); } @@ -217,12 +213,6 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $subSchema = new Schema($version); $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema - // 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/Tests/Fixtures/NotAResource.php b/Tests/Fixtures/NotAResource.php index a3d2292..51cca5b 100644 --- a/Tests/Fixtures/NotAResource.php +++ b/Tests/Fixtures/NotAResource.php @@ -13,7 +13,7 @@ namespace ApiPlatform\JsonSchema\Tests\Fixtures; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * This class is not mapped as an API resource. @@ -23,13 +23,9 @@ class NotAResource { public function __construct( - /** - * @Groups("contain_non_resource") - */ + #[Groups('contain_non_resource')] private $foo, - /** - * @Groups("contain_non_resource") - */ + #[Groups('contain_non_resource')] private $bar ) { } diff --git a/Tests/SchemaFactoryTest.php b/Tests/SchemaFactoryTest.php index 0f66e91..4015f40 100644 --- a/Tests/SchemaFactoryTest.php +++ b/Tests/SchemaFactoryTest.php @@ -78,7 +78,6 @@ public function testBuildSchemaForNonResourceClass(): void $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); $schemaFactory = new SchemaFactory( - typeFactory: null, resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), @@ -155,7 +154,6 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): voi $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); $schemaFactory = new SchemaFactory( - typeFactory: null, resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), @@ -239,7 +237,6 @@ public function testBuildSchemaWithSerializerGroups(): void $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); $schemaFactory = new SchemaFactory( - typeFactory: null, resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), @@ -298,7 +295,6 @@ public function testBuildSchemaForAssociativeArray(): void $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); $schemaFactory = new SchemaFactory( - typeFactory: null, resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), diff --git a/Tests/SchemaTest.php b/Tests/SchemaTest.php index 554d3f2..d574337 100644 --- a/Tests/SchemaTest.php +++ b/Tests/SchemaTest.php @@ -18,9 +18,7 @@ class SchemaTest extends TestCase { - /** - * @dataProvider versionProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('versionProvider')] public function testJsonSchemaVersion(string $version, string $ref): void { $schema = new Schema($version); @@ -31,9 +29,7 @@ public function testJsonSchemaVersion(string $version, string $ref): void $this->assertSame('Foo', $schema->getRootDefinitionKey()); } - /** - * @dataProvider versionProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('versionProvider')] public function testCollectionJsonSchemaVersion(string $version, string $ref): void { $schema = new Schema($version); @@ -62,9 +58,7 @@ public function testContainsJsonSchemaVersion(): void $this->assertSame('http://json-schema.org/draft-07/schema#', $schema['$schema']); } - /** - * @dataProvider definitionsDataProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('definitionsDataProvider')] public function testDefinitions(string $version, array $baseDefinitions): void { $schema = new Schema($version); diff --git a/Tests/TypeFactoryTest.php b/Tests/TypeFactoryTest.php deleted file mode 100644 index e1a5a0c..0000000 --- a/Tests/TypeFactoryTest.php +++ /dev/null @@ -1,472 +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\Tests; - -use ApiPlatform\JsonSchema\Schema; -use ApiPlatform\JsonSchema\SchemaFactoryInterface; -use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\Dummy; -use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\GamePlayMode; -use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\GenderTypeEnum; -use ApiPlatform\JsonSchema\TypeFactory; -use ApiPlatform\Metadata\ResourceClassResolverInterface; -use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type; - -class TypeFactoryTest extends TestCase -{ - use ProphecyTrait; - - /** - * @dataProvider typeProvider - */ - public function testGetType(array $schema, Type $type): void - { - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass(GenderTypeEnum::class)->willReturn(false); - $resourceClassResolver->isResourceClass(Argument::type('string'))->willReturn(true); - $typeFactory = new TypeFactory($resourceClassResolver->reveal()); - $this->assertEquals($schema, $typeFactory->getType($type, 'json', null, null, new Schema(Schema::VERSION_OPENAPI))); - } - - public static function typeProvider(): iterable - { - yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT)]; - yield [['nullable' => true, 'type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT, true)]; - yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT)]; - yield [['nullable' => true, 'type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT, true)]; - yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL)]; - yield [['nullable' => true, 'type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL, true)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING)]; - yield [['nullable' => true, 'type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING, true)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT)]; - yield [['nullable' => true, 'type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT, true)]; - yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)]; - yield [['nullable' => true, 'type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTimeImmutable::class)]; - yield [['type' => 'string', 'format' => 'duration'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateInterval::class)]; - yield [['type' => 'string', 'format' => 'binary'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \SplFileInfo::class)]; - yield [['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; - yield [['nullable' => true, 'type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; - yield ['enum' => ['type' => 'string', 'enum' => ['male', 'female']], new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]; - yield ['nullable enum' => ['type' => 'string', 'enum' => ['male', 'female', null], 'nullable' => true], new Type(Type::BUILTIN_TYPE_OBJECT, true, GenderTypeEnum::class)]; - yield ['enum resource' => ['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, false, GamePlayMode::class)]; - yield ['nullable enum resource' => ['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/', 'nullable' => true], new Type(Type::BUILTIN_TYPE_OBJECT, true, GamePlayMode::class)]; - yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; - yield 'array can be itself nullable' => [ - ['nullable' => true, 'type' => 'array', 'items' => ['type' => 'string']], - new Type(Type::BUILTIN_TYPE_STRING, true, null, true), - ]; - - yield 'array can contain nullable values' => [ - [ - 'type' => 'array', - 'items' => [ - 'nullable' => true, - 'type' => 'string', - ], - ], - new Type(Type::BUILTIN_TYPE_STRING, false, null, true, null, new Type(Type::BUILTIN_TYPE_STRING, true, null, false)), - ]; - - yield 'map with string keys becomes an object' => [ - ['type' => 'object', 'additionalProperties' => ['type' => 'string']], - new Type( - Type::BUILTIN_TYPE_STRING, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false) - ), - ]; - - yield 'nullable map with string keys becomes a nullable object' => [ - [ - 'nullable' => true, - 'type' => 'object', - 'additionalProperties' => ['type' => 'string'], - ], - new Type( - Type::BUILTIN_TYPE_STRING, - true, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_STRING, false, null, false) - ), - ]; - - yield 'map value type will be considered' => [ - ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], - new Type( - Type::BUILTIN_TYPE_ARRAY, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, false, null, false) - ), - ]; - - yield 'map value type nullability will be considered' => [ - [ - 'type' => 'object', - 'additionalProperties' => [ - 'nullable' => true, - 'type' => 'integer', - ], - ], - new Type( - Type::BUILTIN_TYPE_ARRAY, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, true, null, false) - ), - ]; - - yield 'nullable map can contain nullable values' => [ - [ - 'nullable' => true, - 'type' => 'object', - 'additionalProperties' => [ - 'nullable' => true, - 'type' => 'integer', - ], - ], - new Type( - Type::BUILTIN_TYPE_ARRAY, - true, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, true, null, false) - ), - ]; - } - - /** - * @dataProvider jsonSchemaTypeProvider - */ - public function testGetTypeWithJsonSchemaSyntax(array $schema, Type $type): void - { - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass(GenderTypeEnum::class)->willReturn(false); - $resourceClassResolver->isResourceClass(Argument::type('string'))->willReturn(true); - $typeFactory = new TypeFactory($resourceClassResolver->reveal()); - $this->assertEquals($schema, $typeFactory->getType($type, 'json', null, null, new Schema(Schema::VERSION_JSON_SCHEMA))); - } - - public static function jsonSchemaTypeProvider(): iterable - { - yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT)]; - yield [['type' => ['integer', 'null']], new Type(Type::BUILTIN_TYPE_INT, true)]; - yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT)]; - yield [['type' => ['number', 'null']], new Type(Type::BUILTIN_TYPE_FLOAT, true)]; - yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL)]; - yield [['type' => ['boolean', 'null']], new Type(Type::BUILTIN_TYPE_BOOL, true)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING)]; - yield [['type' => ['string', 'null']], new Type(Type::BUILTIN_TYPE_STRING, true)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT)]; - yield [['type' => ['string', 'null']], new Type(Type::BUILTIN_TYPE_OBJECT, true)]; - yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)]; - yield [['type' => ['string', 'null'], 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTimeImmutable::class)]; - yield [['type' => 'string', 'format' => 'duration'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateInterval::class)]; - yield [['type' => 'string', 'format' => 'binary'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \SplFileInfo::class)]; - yield [['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; - yield [['type' => ['string', 'null'], 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; - yield ['enum' => ['type' => 'string', 'enum' => ['male', 'female']], new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]; - yield ['nullable enum' => ['type' => ['string', 'null'], 'enum' => ['male', 'female', null]], new Type(Type::BUILTIN_TYPE_OBJECT, true, GenderTypeEnum::class)]; - yield ['enum resource' => ['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, false, GamePlayMode::class)]; - yield ['nullable enum resource' => ['type' => ['string', 'null'], 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, true, GamePlayMode::class)]; - yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; - yield 'array can be itself nullable' => [ - ['type' => ['array', 'null'], 'items' => ['type' => 'string']], - new Type(Type::BUILTIN_TYPE_STRING, true, null, true), - ]; - - yield 'array can contain nullable values' => [ - [ - 'type' => 'array', - 'items' => [ - 'type' => ['string', 'null'], - ], - ], - new Type(Type::BUILTIN_TYPE_STRING, false, null, true, null, new Type(Type::BUILTIN_TYPE_STRING, true, null, false)), - ]; - - yield 'map with string keys becomes an object' => [ - ['type' => 'object', 'additionalProperties' => ['type' => 'string']], - new Type( - Type::BUILTIN_TYPE_STRING, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false) - ), - ]; - - yield 'nullable map with string keys becomes a nullable object' => [ - [ - 'type' => ['object', 'null'], - 'additionalProperties' => ['type' => 'string'], - ], - new Type( - Type::BUILTIN_TYPE_STRING, - true, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_STRING, false, null, false) - ), - ]; - - yield 'map value type will be considered' => [ - ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], - new Type( - Type::BUILTIN_TYPE_ARRAY, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, false, null, false) - ), - ]; - - yield 'map value type nullability will be considered' => [ - [ - 'type' => 'object', - 'additionalProperties' => [ - 'type' => ['integer', 'null'], - ], - ], - new Type( - Type::BUILTIN_TYPE_ARRAY, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, true, null, false) - ), - ]; - - yield 'nullable map can contain nullable values' => [ - [ - 'type' => ['object', 'null'], - 'additionalProperties' => [ - 'type' => ['integer', 'null'], - ], - ], - new Type( - Type::BUILTIN_TYPE_ARRAY, - true, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, true, null, false) - ), - ]; - } - - /** @dataProvider openAPIV2TypeProvider */ - public function testGetTypeWithOpenAPIV2Syntax(array $schema, Type $type): void - { - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass(GenderTypeEnum::class)->willReturn(false); - $resourceClassResolver->isResourceClass(Argument::type('string'))->willReturn(true); - $typeFactory = new TypeFactory($resourceClassResolver->reveal()); - $this->assertEquals($schema, $typeFactory->getType($type, 'json', null, null, new Schema(Schema::VERSION_SWAGGER))); - } - - public static function openAPIV2TypeProvider(): iterable - { - yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT)]; - yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT, true)]; - yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT)]; - yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT, true)]; - yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL)]; - yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL, true)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING, true)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT)]; - yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_OBJECT, true)]; - yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)]; - yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTimeImmutable::class)]; - yield [['type' => 'string', 'format' => 'duration'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateInterval::class)]; - yield [['type' => 'string', 'format' => 'binary'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \SplFileInfo::class)]; - yield [['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; - yield [['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; - yield ['enum' => ['type' => 'string', 'enum' => ['male', 'female']], new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]; - yield ['nullable enum' => ['type' => 'string', 'enum' => ['male', 'female', null]], new Type(Type::BUILTIN_TYPE_OBJECT, true, GenderTypeEnum::class)]; - yield ['enum resource' => ['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, false, GamePlayMode::class)]; - yield ['nullable enum resource' => ['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], new Type(Type::BUILTIN_TYPE_OBJECT, true, GamePlayMode::class)]; - yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; - yield 'array can be itself nullable, but ignored in OpenAPI V2' => [ - ['type' => 'array', 'items' => ['type' => 'string']], - new Type(Type::BUILTIN_TYPE_STRING, true, null, true), - ]; - - yield 'array can contain nullable values, but ignored in OpenAPI V2' => [ - [ - 'type' => 'array', - 'items' => ['type' => 'string'], - ], - new Type(Type::BUILTIN_TYPE_STRING, false, null, true, null, new Type(Type::BUILTIN_TYPE_STRING, true, null, false)), - ]; - - yield 'map with string keys becomes an object' => [ - ['type' => 'object', 'additionalProperties' => ['type' => 'string']], - new Type( - Type::BUILTIN_TYPE_STRING, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false) - ), - ]; - - yield 'nullable map with string keys becomes a nullable object, but ignored in OpenAPI V2' => [ - [ - 'type' => 'object', - 'additionalProperties' => ['type' => 'string'], - ], - new Type( - Type::BUILTIN_TYPE_STRING, - true, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_STRING, false, null, false) - ), - ]; - - yield 'map value type will be considered' => [ - ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], - new Type( - Type::BUILTIN_TYPE_ARRAY, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, false, null, false) - ), - ]; - - yield 'map value type nullability will be considered, but ignored in OpenAPI V2' => [ - [ - 'type' => 'object', - 'additionalProperties' => ['type' => 'integer'], - ], - new Type( - Type::BUILTIN_TYPE_ARRAY, - false, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, true, null, false) - ), - ]; - - yield 'nullable map can contain nullable values, but ignored in OpenAPI V2' => [ - [ - 'type' => 'object', - 'additionalProperties' => ['type' => 'integer'], - ], - new Type( - Type::BUILTIN_TYPE_ARRAY, - true, - null, - true, - new Type(Type::BUILTIN_TYPE_STRING, false, null, false), - new Type(Type::BUILTIN_TYPE_INT, true, null, false) - ), - ]; - } - - public function testGetClassType(): void - { - $schemaFactoryProphecy = $this->prophesize(SchemaFactoryInterface::class); - - $schemaFactoryProphecy->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, null, Argument::type(Schema::class), Argument::type('array'), false)->will(function (array $args) { - $args[4]['$ref'] = 'ref'; - - return $args[4]; - }); - - $typeFactory = new TypeFactory(); - $typeFactory->setSchemaFactory($schemaFactoryProphecy->reveal()); - - $this->assertEquals(['$ref' => 'ref'], $typeFactory->getType(new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class), 'jsonld', true, ['foo' => 'bar'], new Schema())); - } - - /** @dataProvider classTypeWithNullabilityDataProvider */ - public function testGetClassTypeWithNullability(array $expected, callable $schemaFactoryFactory, Schema $schema): void - { - $typeFactory = new TypeFactory(); - $typeFactory->setSchemaFactory($schemaFactoryFactory($this)); - - self::assertEquals( - $expected, - $typeFactory->getType(new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class), 'jsonld', true, ['foo' => 'bar'], $schema) - ); - } - - public static function classTypeWithNullabilityDataProvider(): iterable - { - $schema = new Schema(); - $schemaFactoryFactory = fn (self $that): SchemaFactoryInterface => $that->createSchemaFactoryMock($schema); - - yield 'JSON-Schema version' => [ - [ - 'anyOf' => [ - ['$ref' => 'the-ref-name'], - ['type' => 'null'], - ], - ], - $schemaFactoryFactory, - $schema, - ]; - - $schema = new Schema(Schema::VERSION_OPENAPI); - $schemaFactoryFactory = fn (self $that): SchemaFactoryInterface => $that->createSchemaFactoryMock($schema); - - yield 'OpenAPI < 3.1 version' => [ - [ - 'anyOf' => [ - ['$ref' => 'the-ref-name'], - ], - 'nullable' => true, - ], - $schemaFactoryFactory, - $schema, - ]; - } - - private function createSchemaFactoryMock(Schema $schema): SchemaFactoryInterface - { - $schemaFactory = $this->createMock(SchemaFactoryInterface::class); - - $schemaFactory - ->method('buildSchema') - ->willReturnCallback(static function () use ($schema): Schema { - $schema['$ref'] = 'the-ref-name'; - $schema['description'] = 'more stuff here'; - - return $schema; - }); - - return $schemaFactory; - } -} diff --git a/TypeFactory.php b/TypeFactory.php deleted file mode 100644 index 3921913..0000000 --- a/TypeFactory.php +++ /dev/null @@ -1,207 +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; - -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; - -/** - * {@inheritdoc} - * - * @deprecated since 3.3 https://github.com/api-platform/core/pull/5470 - * - * @author Kévin Dunglas - */ -final class TypeFactory implements TypeFactoryInterface -{ - use ResourceClassInfoTrait; - - private ?SchemaFactoryInterface $schemaFactory = null; - - public function __construct(?ResourceClassResolverInterface $resourceClassResolver = null) - { - $this->resourceClassResolver = $resourceClassResolver; - } - - public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void - { - $this->schemaFactory = $schemaFactory; - } - - /** - * {@inheritdoc} - */ - 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); - - if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { - return $this->addNullabilityToTypeDefinition([ - 'type' => 'object', - 'additionalProperties' => $this->getType($subType, $format, $readableLink, $serializerContext, $schema), - ], $type, $schema); - } - - return $this->addNullabilityToTypeDefinition([ - 'type' => 'array', - 'items' => $this->getType($subType, $format, $readableLink, $serializerContext, $schema), - ], $type, $schema); - } - - 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 - { - 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(), $format, $readableLink, $serializerContext, $schema), - 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. - */ - private function getClassType(?string $className, bool $nullable, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): 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' : 'integer'; - - if ($nullable) { - $enumCases[] = null; - } - - return [ - 'type' => $type, - 'enum' => $enumCases, - ]; - } - - // Skip if $schema is null (filters only support basic types) - if (null === $schema) { - return ['type' => 'string']; - } - - if (true !== $readableLink && $this->isResourceClass($className)) { - return [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => 'https://example.com/', - ]; - } - - $version = $schema->getVersion(); - - $subSchema = new Schema($version); - $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema - - if (null === $this->schemaFactory) { - throw new \LogicException('The schema factory must be injected by calling the "setSchemaFactory" method.'); - } - - $serializerContext += [SchemaFactory::FORCE_SUBSCHEMA => true]; - $subSchema = $this->schemaFactory->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext, false); - - return ['$ref' => $subSchema['$ref']]; - } - - /** - * @param array $jsonSchema - * - * @return array - */ - private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type, ?Schema $schema): array - { - if ($schema && Schema::VERSION_SWAGGER === $schema->getVersion()) { - return $jsonSchema; - } - - if (!$type->isNullable()) { - return $jsonSchema; - } - - if (\array_key_exists('$ref', $jsonSchema)) { - $typeDefinition = ['anyOf' => [$jsonSchema]]; - - if ($schema && Schema::VERSION_JSON_SCHEMA === $schema->getVersion()) { - $typeDefinition['anyOf'][] = ['type' => 'null']; - } else { - // OpenAPI < 3.1 - $typeDefinition['nullable'] = true; - } - - return $typeDefinition; - } - - if ($schema && Schema::VERSION_JSON_SCHEMA === $schema->getVersion()) { - return [...$jsonSchema, ...[ - 'type' => \is_array($jsonSchema['type']) - ? array_merge($jsonSchema['type'], ['null']) - : [$jsonSchema['type'], 'null'], - ]]; - } - - return [...$jsonSchema, ...['nullable' => true]]; - } -} diff --git a/TypeFactoryInterface.php b/TypeFactoryInterface.php deleted file mode 100644 index b2ba889..0000000 --- a/TypeFactoryInterface.php +++ /dev/null @@ -1,29 +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; - -use Symfony\Component\PropertyInfo\Type; - -/** - * Factory for creating the JSON Schema document which specifies the data type corresponding to a PHP type. - * - * @author Kévin Dunglas - */ -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; -} diff --git a/composer.json b/composer.json index ac440fa..c588536 100644 --- a/composer.json +++ b/composer.json @@ -33,8 +33,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.4 || ^7.0", - "sebastian/comparator": "<5.0" + "symfony/phpunit-bridge": "^6.4 || ^7.0" }, "autoload": { "psr-4": { @@ -66,4 +65,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b33b9e3..3e1f168 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - - ./Tests - ./vendor - - + + + + + + + ./Tests/ + + + + + ./ + + + ./Tests + ./vendor + + - From c0a8465c7fc6b49b45288fa5b517e4ba7a0fafbf Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jul 2024 17:54:26 +0200 Subject: [PATCH 34/73] chore: missing deprecations (#6480) --- SchemaFactory.php | 1 + TypeFactoryInterface.php | 2 ++ 2 files changed, 3 insertions(+) diff --git a/SchemaFactory.php b/SchemaFactory.php index 097eadd..b1b0814 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -42,6 +42,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { if ($typeFactory) { + trigger_deprecation('api-platform/core', '3.4', sprintf('Injecting the "%s" inside "%s" is deprecated and "%s" will be removed in 4.x.', TypeFactoryInterface::class, self::class, TypeFactoryInterface::class)); $this->typeFactory = $typeFactory; } if (!$definitionNameFactory) { diff --git a/TypeFactoryInterface.php b/TypeFactoryInterface.php index b2ba889..70a551f 100644 --- a/TypeFactoryInterface.php +++ b/TypeFactoryInterface.php @@ -18,6 +18,8 @@ /** * Factory for creating the JSON Schema document which specifies the data type corresponding to a PHP type. * + * @deprecated + * * @author Kévin Dunglas */ interface TypeFactoryInterface From 9004326e45e64a9d27f187640fee2b997067848b Mon Sep 17 00:00:00 2001 From: Takashi Kanemoto <4360663+ttskch@users.noreply.github.com> Date: Thu, 8 Aug 2024 23:38:38 +0900 Subject: [PATCH 35/73] cs: remove unnecessary comments (#6408) --- Command/JsonSchemaGenerateCommand.php | 1 - Tests/SchemaFactoryTest.php | 4 ---- Tests/SchemaTest.php | 2 -- 3 files changed, 7 deletions(-) diff --git a/Command/JsonSchemaGenerateCommand.php b/Command/JsonSchemaGenerateCommand.php index 708c83d..ecd492d 100644 --- a/Command/JsonSchemaGenerateCommand.php +++ b/Command/JsonSchemaGenerateCommand.php @@ -31,7 +31,6 @@ */ final class JsonSchemaGenerateCommand extends Command { - // @noRector \Rector\Php81\Rector\Property\ReadOnlyPropertyRector private array $formats; public function __construct(private readonly SchemaFactoryInterface $schemaFactory, array $formats) diff --git a/Tests/SchemaFactoryTest.php b/Tests/SchemaFactoryTest.php index 0f66e91..bf3bd56 100644 --- a/Tests/SchemaFactoryTest.php +++ b/Tests/SchemaFactoryTest.php @@ -91,7 +91,6 @@ public function testBuildSchemaForNonResourceClass(): void $definitions = $resultSchema->getDefinitions(); $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - // @noRector $this->assertTrue(isset($definitions[$rootDefinitionKey])); $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); @@ -168,7 +167,6 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): voi $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']); @@ -252,7 +250,6 @@ public function testBuildSchemaWithSerializerGroups(): void $definitions = $resultSchema->getDefinitions(); $this->assertSame((new \ReflectionClass(OverriddenOperationDummy::class))->getShortName().'-'.$serializerGroup, $rootDefinitionKey); - // @noRector $this->assertTrue(isset($definitions[$rootDefinitionKey])); $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); @@ -311,7 +308,6 @@ public function testBuildSchemaForAssociativeArray(): void $definitions = $resultSchema->getDefinitions(); $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - // @noRector $this->assertTrue(isset($definitions[$rootDefinitionKey])); $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); $this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']); diff --git a/Tests/SchemaTest.php b/Tests/SchemaTest.php index 554d3f2..73175d0 100644 --- a/Tests/SchemaTest.php +++ b/Tests/SchemaTest.php @@ -73,12 +73,10 @@ public function testDefinitions(string $version, array $baseDefinitions): void if (Schema::VERSION_OPENAPI === $version) { $this->assertArrayHasKey('schemas', $schema['components']); } else { - // @noRector $this->assertTrue(isset($schema['definitions'])); } $definitions = $schema->getDefinitions(); - // @noRector $this->assertTrue(isset($definitions['foo'])); $this->assertArrayNotHasKey('definitions', $schema->getArrayCopy(false)); From 1e77925937956f637f0c0f262a09b757cfc0b552 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 9 Aug 2024 09:21:07 +0200 Subject: [PATCH 36/73] style: various cs fixes (#6504) * cs: fixes * chore: phpstan fixes --- Command/JsonSchemaGenerateCommand.php | 10 +++++----- DefinitionNameFactory.php | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Command/JsonSchemaGenerateCommand.php b/Command/JsonSchemaGenerateCommand.php index ecd492d..eebcf5d 100644 --- a/Command/JsonSchemaGenerateCommand.php +++ b/Command/JsonSchemaGenerateCommand.php @@ -50,7 +50,7 @@ protected function configure(): void ->addArgument('resource', InputArgument::REQUIRED, 'The Fully Qualified Class Name (FQCN) of the resource') ->addOption('operation', null, InputOption::VALUE_REQUIRED, 'The operation name') ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The response format', (string) $this->formats[0]) - ->addOption('type', null, InputOption::VALUE_REQUIRED, sprintf('The type of schema to generate (%s or %s)', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT), Schema::TYPE_INPUT); + ->addOption('type', null, InputOption::VALUE_REQUIRED, \sprintf('The type of schema to generate (%s or %s)', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT), Schema::TYPE_INPUT); } /** @@ -69,19 +69,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $type = $input->getOption('type'); if (!\in_array($type, [Schema::TYPE_INPUT, Schema::TYPE_OUTPUT], true)) { - $io->error(sprintf('You can only use "%s" or "%s" for the "type" option', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT)); + $io->error(\sprintf('You can only use "%s" or "%s" for the "type" option', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT)); return 1; } if (!\in_array($format, $this->formats, true)) { - throw new InvalidOptionException(sprintf('The response format "%s" is not supported. Supported formats are : %s.', $format, implode(', ', $this->formats))); + throw new InvalidOptionException(\sprintf('The response format "%s" is not supported. Supported formats are : %s.', $format, implode(', ', $this->formats))); } - $schema = $this->schemaFactory->buildSchema($resource, $format, $type, $operation ? (new class() extends HttpOperation {})->withName($operation) : null); + $schema = $this->schemaFactory->buildSchema($resource, $format, $type, $operation ? (new class extends HttpOperation {})->withName($operation) : null); if (!$schema->isDefined()) { - $io->error(sprintf('There is no %s defined for the operation "%s" of the resource "%s".', $type, $operation, $resource)); + $io->error(\sprintf('There is no %s defined for the operation "%s" of the resource "%s".', $type, $operation, $resource)); return 1; } diff --git a/DefinitionNameFactory.php b/DefinitionNameFactory.php index 4007e83..86076de 100644 --- a/DefinitionNameFactory.php +++ b/DefinitionNameFactory.php @@ -48,10 +48,10 @@ public function create(string $className, string $format = 'json', ?string $inpu $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null; if ($definitionName) { - $name = sprintf('%s-%s', $prefix, $definitionName); + $name = \sprintf('%s-%s', $prefix, $definitionName); } else { $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); - $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; + $name = $groups ? \sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; } return $this->encodeDefinitionName($name); From 9874480d51342defebf614299d5f544e63c7aca4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 9 Aug 2024 09:35:20 +0200 Subject: [PATCH 37/73] style: cs fixes --- SchemaFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SchemaFactory.php b/SchemaFactory.php index b1b0814..610c242 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -42,7 +42,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { if ($typeFactory) { - trigger_deprecation('api-platform/core', '3.4', sprintf('Injecting the "%s" inside "%s" is deprecated and "%s" will be removed in 4.x.', TypeFactoryInterface::class, self::class, TypeFactoryInterface::class)); + trigger_deprecation('api-platform/core', '3.4', \sprintf('Injecting the "%s" inside "%s" is deprecated and "%s" will be removed in 4.x.', TypeFactoryInterface::class, self::class, TypeFactoryInterface::class)); $this->typeFactory = $typeFactory; } if (!$definitionNameFactory) { From 37bd914ac714b682910de4cbdb4854da6df6804d Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 9 Aug 2024 09:49:50 +0200 Subject: [PATCH 38/73] style: cs fixes --- Command/JsonSchemaGenerateCommand.php | 10 +++++----- DefinitionNameFactory.php | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Command/JsonSchemaGenerateCommand.php b/Command/JsonSchemaGenerateCommand.php index 708c83d..835f7de 100644 --- a/Command/JsonSchemaGenerateCommand.php +++ b/Command/JsonSchemaGenerateCommand.php @@ -51,7 +51,7 @@ protected function configure(): void ->addArgument('resource', InputArgument::REQUIRED, 'The Fully Qualified Class Name (FQCN) of the resource') ->addOption('operation', null, InputOption::VALUE_REQUIRED, 'The operation name') ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The response format', (string) $this->formats[0]) - ->addOption('type', null, InputOption::VALUE_REQUIRED, sprintf('The type of schema to generate (%s or %s)', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT), Schema::TYPE_INPUT); + ->addOption('type', null, InputOption::VALUE_REQUIRED, \sprintf('The type of schema to generate (%s or %s)', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT), Schema::TYPE_INPUT); } /** @@ -70,19 +70,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $type = $input->getOption('type'); if (!\in_array($type, [Schema::TYPE_INPUT, Schema::TYPE_OUTPUT], true)) { - $io->error(sprintf('You can only use "%s" or "%s" for the "type" option', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT)); + $io->error(\sprintf('You can only use "%s" or "%s" for the "type" option', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT)); return 1; } if (!\in_array($format, $this->formats, true)) { - throw new InvalidOptionException(sprintf('The response format "%s" is not supported. Supported formats are : %s.', $format, implode(', ', $this->formats))); + throw new InvalidOptionException(\sprintf('The response format "%s" is not supported. Supported formats are : %s.', $format, implode(', ', $this->formats))); } - $schema = $this->schemaFactory->buildSchema($resource, $format, $type, $operation ? (new class() extends HttpOperation {})->withName($operation) : null); + $schema = $this->schemaFactory->buildSchema($resource, $format, $type, $operation ? (new class extends HttpOperation {})->withName($operation) : null); if (!$schema->isDefined()) { - $io->error(sprintf('There is no %s defined for the operation "%s" of the resource "%s".', $type, $operation, $resource)); + $io->error(\sprintf('There is no %s defined for the operation "%s" of the resource "%s".', $type, $operation, $resource)); return 1; } diff --git a/DefinitionNameFactory.php b/DefinitionNameFactory.php index 4007e83..86076de 100644 --- a/DefinitionNameFactory.php +++ b/DefinitionNameFactory.php @@ -48,10 +48,10 @@ public function create(string $className, string $format = 'json', ?string $inpu $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null; if ($definitionName) { - $name = sprintf('%s-%s', $prefix, $definitionName); + $name = \sprintf('%s-%s', $prefix, $definitionName); } else { $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); - $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; + $name = $groups ? \sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; } return $this->encodeDefinitionName($name); From bb1bc5168cb30d370ea551b2e7ba755041688b32 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 9 Aug 2024 19:39:12 +0200 Subject: [PATCH 39/73] Merge 3.4 (#6507) * tests: remove output suffix after reverting * cs: remove unnecessary comments (#6408) * fix(elasticsearch): change normalize return type to compatible with other normalizers (#6493) * style: various cs fixes (#6504) * cs: fixes * chore: phpstan fixes * style: cs fixes --------- Co-authored-by: Takashi Kanemoto <4360663+ttskch@users.noreply.github.com> Co-authored-by: Koen Pasman <118996061+Koenstell@users.noreply.github.com> --- Command/JsonSchemaGenerateCommand.php | 1 - Tests/SchemaFactoryTest.php | 4 ---- Tests/SchemaTest.php | 2 -- 3 files changed, 7 deletions(-) diff --git a/Command/JsonSchemaGenerateCommand.php b/Command/JsonSchemaGenerateCommand.php index 835f7de..eebcf5d 100644 --- a/Command/JsonSchemaGenerateCommand.php +++ b/Command/JsonSchemaGenerateCommand.php @@ -31,7 +31,6 @@ */ final class JsonSchemaGenerateCommand extends Command { - // @noRector \Rector\Php81\Rector\Property\ReadOnlyPropertyRector private array $formats; public function __construct(private readonly SchemaFactoryInterface $schemaFactory, array $formats) diff --git a/Tests/SchemaFactoryTest.php b/Tests/SchemaFactoryTest.php index 4015f40..da38fc6 100644 --- a/Tests/SchemaFactoryTest.php +++ b/Tests/SchemaFactoryTest.php @@ -90,7 +90,6 @@ public function testBuildSchemaForNonResourceClass(): void $definitions = $resultSchema->getDefinitions(); $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - // @noRector $this->assertTrue(isset($definitions[$rootDefinitionKey])); $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); @@ -166,7 +165,6 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): voi $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']); @@ -249,7 +247,6 @@ public function testBuildSchemaWithSerializerGroups(): void $definitions = $resultSchema->getDefinitions(); $this->assertSame((new \ReflectionClass(OverriddenOperationDummy::class))->getShortName().'-'.$serializerGroup, $rootDefinitionKey); - // @noRector $this->assertTrue(isset($definitions[$rootDefinitionKey])); $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); @@ -307,7 +304,6 @@ public function testBuildSchemaForAssociativeArray(): void $definitions = $resultSchema->getDefinitions(); $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - // @noRector $this->assertTrue(isset($definitions[$rootDefinitionKey])); $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); $this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']); diff --git a/Tests/SchemaTest.php b/Tests/SchemaTest.php index d574337..d60ab82 100644 --- a/Tests/SchemaTest.php +++ b/Tests/SchemaTest.php @@ -67,12 +67,10 @@ public function testDefinitions(string $version, array $baseDefinitions): void if (Schema::VERSION_OPENAPI === $version) { $this->assertArrayHasKey('schemas', $schema['components']); } else { - // @noRector $this->assertTrue(isset($schema['definitions'])); } $definitions = $schema->getDefinitions(); - // @noRector $this->assertTrue(isset($definitions['foo'])); $this->assertArrayNotHasKey('definitions', $schema->getArrayCopy(false)); From e9987d0dade52cfc52998dd71309eee9b5844940 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 09:26:44 +0200 Subject: [PATCH 40/73] chore: bump api-platform dependencies --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c588536..ec20624 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "*@dev || ^3.1", + "api-platform/metadata": "*@dev || ^3.2 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", From 5a63a718775197ded275958808928e39710fdda7 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 12 Aug 2024 12:17:47 +0200 Subject: [PATCH 41/73] chore: update branch aliases with 3.4 and 4.0 (#6509) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ec20624..36276c4 100644 --- a/composer.json +++ b/composer.json @@ -65,4 +65,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From 49b44c7949736265be4360cde1c03f3deb772ed1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:36:55 +0200 Subject: [PATCH 42/73] cs: newline ending --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 36276c4..ec20624 100644 --- a/composer.json +++ b/composer.json @@ -65,4 +65,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From d8ef09299606eb42ec16388f9cb54188b9466504 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:44:43 +0200 Subject: [PATCH 43/73] chore: align dependencies across components --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ec20624..c06fed2 100644 --- a/composer.json +++ b/composer.json @@ -25,14 +25,14 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "*@dev || ^3.2 || ^4.0", + "api-platform/metadata": "@dev || ^3.2 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", + "phpspec/prophecy-phpunit": "^2.2", "symfony/phpunit-bridge": "^6.4 || ^7.0" }, "autoload": { From 5aa28392230af0268641cb839843f93e7f01edd2 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 19 Aug 2024 14:51:02 +0200 Subject: [PATCH 44/73] chore: remove @dev constraint (#6513) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c06fed2..1fa5a32 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "@dev || ^3.2 || ^4.0", + "api-platform/metadata": "^3.2 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", From 3c3649347d35affa1ac732efcf219b7e2cc6bef5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 30 Aug 2024 22:19:12 +0200 Subject: [PATCH 45/73] chore: deprecations (#6563) * chore: deprecations * chore: deprecations --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ac440fa..6a61f93 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "*@dev || ^3.1", + "api-platform/metadata": "^3.1", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", @@ -66,4 +66,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From 15e1a92cd6180912f504e1e0866c936b8130f5ac Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 31 Aug 2024 09:57:10 +0200 Subject: [PATCH 46/73] style: cs-fixer update (#6568) --- Tests/Fixtures/NotAResource.php | 2 +- Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Fixtures/NotAResource.php b/Tests/Fixtures/NotAResource.php index a3d2292..f6a5519 100644 --- a/Tests/Fixtures/NotAResource.php +++ b/Tests/Fixtures/NotAResource.php @@ -30,7 +30,7 @@ public function __construct( /** * @Groups("contain_non_resource") */ - private $bar + private $bar, ) { } diff --git a/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php index 1af958a..d28cf68 100644 --- a/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php +++ b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php @@ -24,7 +24,7 @@ class NotAResourceWithUnionIntersectTypes public function __construct( private $ignoredProperty, private string|int|float|null $unionType, - private Serializable&DummyResourceInterface $intersectType + private Serializable&DummyResourceInterface $intersectType, ) { } From 6f200a254757251358e64a1e4cd0abcb7e6b5875 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 31 Aug 2024 10:36:15 +0200 Subject: [PATCH 47/73] Merge 3.4 (#6569) * style: cs-fixer update (#6568) * style: cs-fixer update --- Tests/Fixtures/NotAResource.php | 2 +- Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Fixtures/NotAResource.php b/Tests/Fixtures/NotAResource.php index 51cca5b..263c72b 100644 --- a/Tests/Fixtures/NotAResource.php +++ b/Tests/Fixtures/NotAResource.php @@ -26,7 +26,7 @@ public function __construct( #[Groups('contain_non_resource')] private $foo, #[Groups('contain_non_resource')] - private $bar + private $bar, ) { } diff --git a/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php index 1af958a..d28cf68 100644 --- a/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php +++ b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php @@ -24,7 +24,7 @@ class NotAResourceWithUnionIntersectTypes public function __construct( private $ignoredProperty, private string|int|float|null $unionType, - private Serializable&DummyResourceInterface $intersectType + private Serializable&DummyResourceInterface $intersectType, ) { } From 5485b242f7ab18ea5466dface4fef57b97c02255 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 3 Sep 2024 09:00:23 +0200 Subject: [PATCH 48/73] chore: bump dependencies to ^3.4 || ^4.0 (#6576) * chore: bump dependencies to ^3.4 || ^4.0 * fix: use baseline for serializer context builder * ci: use pmu 0.12 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6a61f93..02d6843 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "^3.1", + "api-platform/metadata": "^3.4 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", From e5c9d3d2168a92f6f5269f974cc0274e92a05e4c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Sep 2024 08:35:15 +0200 Subject: [PATCH 49/73] chore: remove phpunit-bridge (#6589) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3122eba..3c7704a 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "symfony/phpunit-bridge": "^6.4 || ^7.0" + "phpunit/phpunit": "^11.2" }, "autoload": { "psr-4": { From 58cd51004c1a2d0d5bf36c7b71c818d673031f89 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Sat, 7 Sep 2024 08:31:56 +0200 Subject: [PATCH 50/73] fix: replace ApiPlatform\Exception use by ApiPlatform\Metadata\Exception (#6597) --- Metadata/Property/Factory/SchemaPropertyMetadataFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index d5070f8..2be0459 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -13,9 +13,9 @@ namespace ApiPlatform\JsonSchema\Metadata\Property\Factory; -use ApiPlatform\Exception\PropertyNotFoundException; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; From f23596da20a564a958da89352526600861d54819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Thu, 19 Sep 2024 17:56:07 +0200 Subject: [PATCH 51/73] chore: setup star forwarding (#6630) --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 02d6843..dab8560 100644 --- a/composer.json +++ b/composer.json @@ -61,6 +61,10 @@ }, "symfony": { "require": "^6.4 || ^7.1" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "scripts": { From fe8cd41142e68633a7a0633cbad78b993c7165a1 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 19 Sep 2024 17:56:20 +0200 Subject: [PATCH 52/73] chore: php version >= 8.2 (#6628) * chore: php version >= 8.2 * down to sf 7.0 * symfony extra require * missing hal constraint --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 3c7704a..92c3d6e 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,11 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "api-platform/metadata": "^3.4 || ^4.0", "symfony/console": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.1", - "symfony/serializer": "^6.4 || ^7.1", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { @@ -59,7 +59,7 @@ "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.1" + "require": "^6.4 || ^7.0" } }, "scripts": { From 8d6ff6e0250f07a1e05959a7e60c40344ce0d920 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Sep 2024 17:58:51 +0200 Subject: [PATCH 53/73] chore: fix thanks url --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 92c3d6e..0695729 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,10 @@ }, "symfony": { "require": "^6.4 || ^7.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "scripts": { From efba030fbd083585f6fdd8ed48aaa4b44a866a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 20 Sep 2024 15:47:23 +0200 Subject: [PATCH 54/73] chore: add GitHub Action to automatically close PRs on subtree splits (#6648) --- .gitattributes | 3 +++ .github/workflows/close_pr.yml | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .github/workflows/close_pr.yml diff --git a/.gitattributes b/.gitattributes index ae3c2e1..801f208 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +/.github export-ignore +/.gitattributes export-ignore /.gitignore export-ignore /Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/close_pr.yml b/.github/workflows/close_pr.yml new file mode 100644 index 0000000..72a8ab4 --- /dev/null +++ b/.github/workflows/close_pr.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Thank you for your pull request. However, you have submitted this PR on a read-only sub split of `api-platform/core`. Please submit your PR on the https://github.com/api-platform/core repository.

Thanks!" From 3a97e7483e0142b57d89bcd0fd8774fda9906a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 21 Sep 2024 12:54:56 +0200 Subject: [PATCH 55/73] doc: add README files for components (#6653) * docs: add README for components * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * remove trailing spaces * typo * better title --------- Co-authored-by: Antoine Bluchet --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 45d4d62..7ddde68 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ # API Platform - JSON Schema -Build a JSON Schema from API Resources. +The [JSON Schema](https://json-schema.org/) component of the [API Platform](https://api-platform.com) framework. -## Resources +Generates JSON Schema from PHP classes. +[Documentation](https://api-platform.com/docs/core/json-schema/) +> [!CAUTION] +> +> This is a read-only sub split of `api-platform/core`, please +> [report issues](https://github.com/api-platform/core/issues) and +> [send Pull Requests](https://github.com/api-platform/core/pulls) +> in the [core API Platform repository](https://github.com/api-platform/core). From 76cd01a9dd1e4744599b091321ff7ae7d7e70bc1 Mon Sep 17 00:00:00 2001 From: Robert <36849548+nightio@users.noreply.github.com> Date: Tue, 1 Oct 2024 09:55:44 +0200 Subject: [PATCH 56/73] fix(jsonapi): fixed definition name to allow using the same class names in different namespaces (#6676) Co-authored-by: Robert --- DefinitionNameFactory.php | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/DefinitionNameFactory.php b/DefinitionNameFactory.php index 86076de..9cfbb7d 100644 --- a/DefinitionNameFactory.php +++ b/DefinitionNameFactory.php @@ -21,6 +21,9 @@ final class DefinitionNameFactory implements DefinitionNameFactoryInterface { use ResourceClassInfoTrait; + private const GLUE = '.'; + private array $prefixCache = []; + public function __construct(private ?array $distinctFormats) { } @@ -32,18 +35,18 @@ public function create(string $className, string $format = 'json', ?string $inpu } if (!isset($prefix)) { - $prefix = (new \ReflectionClass($className))->getShortName(); + $prefix = $this->createPrefixFromClass($className); } if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { $parts = explode('\\', $inputOrOutputClass); $shortName = end($parts); - $prefix .= '.'.$shortName; + $prefix .= self::GLUE.$shortName; } if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) { // JSON is the default, and so isn't included in the definition name - $prefix .= '.'.$format; + $prefix .= self::GLUE.$format; } $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null; @@ -61,4 +64,22 @@ private function encodeDefinitionName(string $name): string { return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name); } + + 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; + } } From de78f6c7244757c67c2afecdd6b72ed94582cda5 Mon Sep 17 00:00:00 2001 From: Martin Chudoba Date: Mon, 14 Oct 2024 11:09:36 +0200 Subject: [PATCH 57/73] fix(jsonschema): handle @id when genId is false (#6716) Co-authored-by: soyuka --- SchemaFactory.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/SchemaFactory.php b/SchemaFactory.php index 610c242..9c6c6a3 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -232,6 +232,14 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str continue; } + if (false === $propertyMetadata->getGenId()) { + $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext); + + if (isset($subSchema->getDefinitions()[$subDefinitionName])) { + unset($subSchema->getDefinitions()[$subDefinitionName]['properties']['@id']); + } + } + if ($isCollection) { $propertySchema['items']['$ref'] = $subSchema['$ref']; unset($propertySchema['items']['type']); From b01eb55ac4bb8337674818f97035b39b9b61d66b Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sat, 7 Dec 2024 12:04:27 +0100 Subject: [PATCH 58/73] fix(jsonschema): hashmaps produces invalid openapi schema (#6830) * fix(jsonschema): hashmaps produces invalid openapi schema * fix --------- Co-authored-by: soyuka --- .../Factory/SchemaPropertyMetadataFactory.php | 14 +++++++++----- SchemaFactory.php | 8 +++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 2be0459..b9f4845 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -34,8 +34,10 @@ final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInte public const JSON_SCHEMA_USER_DEFINED = 'user_defined_schema'; - public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null) - { + public function __construct( + ResourceClassResolverInterface $resourceClassResolver, + private readonly ?PropertyMetadataFactoryInterface $decorated = null, + ) { $this->resourceClassResolver = $resourceClassResolver; } @@ -198,6 +200,8 @@ private function typeToArray(Type $type, ?bool $readableLink = null): array * 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. + * + * @throws PropertyNotFoundException */ private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array { @@ -240,7 +244,8 @@ private function getClassType(?string $className, bool $nullable, ?bool $readabl ]; } - if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) { + $isResourceClass = $this->isResourceClass($className); + if (!$isResourceClass && 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' : 'integer'; @@ -255,7 +260,7 @@ private function getClassType(?string $className, bool $nullable, ?bool $readabl ]; } - if (true !== $readableLink && $this->isResourceClass($className)) { + if (true !== $readableLink && $isResourceClass) { return [ 'type' => 'string', 'format' => 'iri-reference', @@ -263,7 +268,6 @@ private function getClassType(?string $className, bool $nullable, ?bool $readabl ]; } - // 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/SchemaFactory.php b/SchemaFactory.php index 9c6c6a3..2110324 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -183,7 +183,8 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $propertySchemaType = $propertySchema['type'] ?? false; $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType - || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)); + || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)) + || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null)); if ( !$isUnknown && ( @@ -241,8 +242,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } if ($isCollection) { - $propertySchema['items']['$ref'] = $subSchema['$ref']; - unset($propertySchema['items']['type']); + $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; + $propertySchema[$key]['$ref'] = $subSchema['$ref']; + unset($propertySchema[$key]['type']); break; } From a1dff7eebefd3a64799418c8a2cc40fff4ab42fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20L=C3=A9v=C3=AAque?= Date: Thu, 16 Jan 2025 08:34:23 +0100 Subject: [PATCH 59/73] fix(openapi): typing issue with `openapiContext` in `#[ApiProperty]` (#6910) --- .../Factory/SchemaPropertyMetadataFactory.php | 2 +- .../DummyWithCustomOpenApiContext.php | 6 ++++ .../SchemaPropertyMetadataFactoryTest.php | 29 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index b9f4845..5ed3bb2 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -106,7 +106,7 @@ public function create(string $resourceClass, string $property, array $options = // never override the following keys if at least one is already set or if there's a custom openapi context if ([] === $types || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) - || ($propertyMetadata->getOpenapiContext() ?? false) + || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? []) ) { return $propertyMetadata->withSchema($propertySchema); } diff --git a/Tests/Fixtures/DummyWithCustomOpenApiContext.php b/Tests/Fixtures/DummyWithCustomOpenApiContext.php index 22517c6..c676ea8 100644 --- a/Tests/Fixtures/DummyWithCustomOpenApiContext.php +++ b/Tests/Fixtures/DummyWithCustomOpenApiContext.php @@ -30,4 +30,10 @@ class DummyWithCustomOpenApiContext { #[ApiProperty(openapiContext: ['type' => 'object', 'properties' => ['alpha' => ['type' => 'integer']]])] public $acme; + + #[ApiProperty(openapiContext: ['description' => 'My description'])] + public bool $foo; + + #[ApiProperty(openapiContext: ['iris' => 'https://schema.org/Date'])] + public \DateTimeImmutable $bar; } diff --git a/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php index ec08170..3e3cc1f 100644 --- a/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php +++ b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -49,4 +49,33 @@ public function testWithCustomOpenApiContext(): void $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'acme'); $this->assertEquals([], $apiProperty->getSchema()); } + + public function testWithCustomOpenApiContextWithoutTypeDefinition(): void + { + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty( + openapiContext: ['description' => 'My description'], + builtinTypes: [new Type(builtinType: 'bool')], + ); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'foo')->willReturn($apiProperty); + $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); + $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'foo'); + $this->assertEquals([ + 'type' => 'boolean', + ], $apiProperty->getSchema()); + + $apiProperty = new ApiProperty( + openapiContext: ['iris' => 'https://schema.org/Date'], + builtinTypes: [new Type(builtinType: 'object', class: \DateTimeImmutable::class)], + ); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'bar')->willReturn($apiProperty); + $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); + $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'bar'); + $this->assertEquals([ + 'type' => 'string', + 'format' => 'date-time', + ], $apiProperty->getSchema()); + } } From 1d4f9a724b84cd242d66770b2001f1cfcb80ec87 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jan 2025 17:16:57 +0100 Subject: [PATCH 60/73] feat(openapi): document error outputs using json-schemas (#6923) --- ResourceMetadataTrait.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/ResourceMetadataTrait.php b/ResourceMetadataTrait.php index 51232c3..64e47fe 100644 --- a/ResourceMetadataTrait.php +++ b/ResourceMetadataTrait.php @@ -36,7 +36,7 @@ private function findOutputClass(string $className, string $type, Operation $ope return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); } - private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext): Operation + private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext, ?string $format = null): Operation { if (null === $operation) { if (null === $this->resourceMetadataFactory) { @@ -54,7 +54,7 @@ private function findOperation(string $className, string $type, ?Operation $oper $operation = new HttpOperation(); } - return $this->findOperationForType($resourceMetadataCollection, $type, $operation); + return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $format); } // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise @@ -65,13 +65,13 @@ private function findOperation(string $className, string $type, ?Operation $oper return $resourceMetadataCollection->getOperation($operation->getName()); } - return $this->findOperationForType($resourceMetadataCollection, $type, $operation); + return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $format); } return $operation; } - private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation + private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation, ?string $format = null): Operation { // Find the operation and use the first one that matches criterias foreach ($resourceMetadataCollection as $resourceMetadata) { @@ -85,6 +85,11 @@ private function findOperationForType(ResourceMetadataCollection $resourceMetada $operation = $op; break 2; } + + if ($format && Schema::TYPE_OUTPUT === $type && \array_key_exists($format, $op->getOutputFormats() ?? [])) { + $operation = $op; + break 2; + } } } From 115f429014216401b19a128932fa9f45c493680b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 7 Feb 2025 15:44:27 +0100 Subject: [PATCH 61/73] fix: errors retrieval and documentation (#6952) --- DefinitionNameFactory.php | 4 ++-- ResourceMetadataTrait.php | 2 +- SchemaFactory.php | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/DefinitionNameFactory.php b/DefinitionNameFactory.php index 9cfbb7d..26d3a91 100644 --- a/DefinitionNameFactory.php +++ b/DefinitionNameFactory.php @@ -50,8 +50,8 @@ public function create(string $className, string $format = 'json', ?string $inpu } $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null; - if ($definitionName) { - $name = \sprintf('%s-%s', $prefix, $definitionName); + if (null !== $definitionName) { + $name = \sprintf('%s%s', $prefix, $definitionName ? '-'.$definitionName : $definitionName); } else { $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); $name = $groups ? \sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; diff --git a/ResourceMetadataTrait.php b/ResourceMetadataTrait.php index 64e47fe..52d10e0 100644 --- a/ResourceMetadataTrait.php +++ b/ResourceMetadataTrait.php @@ -54,7 +54,7 @@ private function findOperation(string $className, string $type, ?Operation $oper $operation = new HttpOperation(); } - return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $format); + return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $forceSubschema ? null : $format); } // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise diff --git a/SchemaFactory.php b/SchemaFactory.php index d59c7e3..0ff0476 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -61,7 +61,7 @@ public function buildSchema(string $className, string $format = 'json', string $ $inputOrOutputClass = $className; $serializerContext ??= []; } else { - $operation = $this->findOperation($className, $type, $operation, $serializerContext); + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); $serializerContext ??= $this->getSerializerContext($operation, $type); } @@ -74,7 +74,6 @@ public function buildSchema(string $className, string $format = 'json', string $ $validationGroups = $operation ? $this->getValidationGroups($operation) : []; $version = $schema->getVersion(); $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext); - $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; if (!$operation) { $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET'; @@ -291,6 +290,10 @@ private function getFactoryOptions(array $serializerContext, array $validationGr $options['validation_groups'] = $validationGroups; } + if ($operation && ($ignoredAttributes = $operation->getNormalizationContext()['ignored_attributes'] ?? null)) { + $options['ignored_attributes'] = $ignoredAttributes; + } + return $options; } From 2855669b064c60c27058705bfe45bb4312f9d127 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:08:08 +0100 Subject: [PATCH 62/73] chore: dependency constraints (#6988) --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 0695729..21a88b4 100644 --- a/composer.json +++ b/composer.json @@ -25,9 +25,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "symfony/console": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0" }, @@ -55,7 +55,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.0.x-dev", + "dev-main": "4.2.x-dev", "dev-3.4": "3.4.x-dev" }, "symfony": { From 5a9d58b6ade32b156bb8fe9c15a6a5240775d822 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:39:00 +0100 Subject: [PATCH 63/73] chore: dependency constraints for laravel 12 (#6989) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0695729..3e2f14b 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "php": ">=8.2", "api-platform/metadata": "^3.4 || ^4.0", "symfony/console": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0" }, From 32bf153319c1d4778f6daafe00d636508ee2c4d9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 11 Apr 2025 11:32:56 +0200 Subject: [PATCH 64/73] chore: phpunit missing deprecation triggers (#7059) --- phpunit.xml.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3e1f168..25e03f6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,6 +9,9 @@ + + trigger_deprecation + ./ From a59f153f99458455f92717cefc4c7a00e4f883f9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 18 Apr 2025 10:39:51 +0200 Subject: [PATCH 65/73] ci: patch phpunit deprecations inside component (#7103) --- composer.json | 10 ++++++++-- phpunit.xml.dist | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 21a88b4..ebdc30a 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "11.5.x-dev" }, "autoload": { "psr-4": { @@ -68,5 +68,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/soyuka/phpunit" + } + ] } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 25e03f6..d02b29b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,7 @@ ./Tests/ - + trigger_deprecation From 82b21c0b7a85b0c2c10fdf6f1a88957266a39f8a Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:29:32 +0200 Subject: [PATCH 66/73] fix: command name deprecation --- Command/JsonSchemaGenerateCommand.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Command/JsonSchemaGenerateCommand.php b/Command/JsonSchemaGenerateCommand.php index eebcf5d..7b2f282 100644 --- a/Command/JsonSchemaGenerateCommand.php +++ b/Command/JsonSchemaGenerateCommand.php @@ -16,6 +16,7 @@ use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Metadata\HttpOperation; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Input\InputArgument; @@ -29,6 +30,7 @@ * * @author Jacques Lefebvre */ +#[AsCommand(name: 'api:json-schema:generate')] final class JsonSchemaGenerateCommand extends Command { private array $formats; @@ -90,9 +92,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - - public static function getDefaultName(): string - { - return 'api:json-schema:generate'; - } } From dbe8f7d3e6a647abc90027d3e0f4e4b1bcbb8d62 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:13:30 +0200 Subject: [PATCH 67/73] chore: bump patch dependencies --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ebdc30a..72ce292 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", + "api-platform/metadata": "^4.1.11", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", From 596b1d30addca9e7d550556bd95443eabb0973ce Mon Sep 17 00:00:00 2001 From: Billy Mijnendonckx Date: Sat, 24 May 2025 03:15:02 +0200 Subject: [PATCH 68/73] fix(openapi): `example` and `default` with nullable value not being shown --- .../Factory/SchemaPropertyMetadataFactory.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 5ed3bb2..9d0be40 100644 --- a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -88,14 +88,19 @@ public function create(string $resourceClass, string $property, array $options = $types = $propertyMetadata->getBuiltinTypes() ?? []; - if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) { + if ( + !\array_key_exists('default', $propertySchema) + && null !== ($default = $propertyMetadata->getDefault()) + && false === (\is_array($default) && empty($default)) + && (!\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())) { + if (!\array_key_exists('example', $propertySchema) && null !== ($example = $propertyMetadata->getExample()) && false === (\is_array($example) && empty($example))) { $propertySchema['example'] = $example; } @@ -104,7 +109,8 @@ public function create(string $resourceClass, string $property, array $options = } // never override the following keys if at least one is already set or if there's a custom openapi context - if ([] === $types + if ( + [] === $types || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? []) ) { From 99ce7f819da681d90c1a065c4a0dc0c3e1993606 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 May 2025 10:03:08 +0200 Subject: [PATCH 69/73] ci: prefer-lowest to avoid bumping inter components dependencies (#7169) --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 72ce292..e6aa928 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,8 @@ "extra": { "branch-alias": { "dev-main": "4.2.x-dev", - "dev-3.4": "3.4.x-dev" + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { "require": "^6.4 || ^7.0" @@ -74,5 +75,6 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ] + ], + "version": "4.1.12" } From e57e7a25642c8d67bd5bfed96041e217150b34ce Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 4 Jun 2025 12:13:07 +0200 Subject: [PATCH 70/73] ci: symfony 7.3 deprecations (#7192) --- phpunit.baseline.xml | 8 ++++++++ phpunit.xml.dist | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 phpunit.baseline.xml diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml new file mode 100644 index 0000000..e3ef619 --- /dev/null +++ b/phpunit.baseline.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d02b29b..1987ee6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,7 @@ ./Tests/ - + trigger_deprecation From fd6f519f723f8bbca125381419b8d0cf3c49f537 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:02:15 +0200 Subject: [PATCH 71/73] fix: bump composer.json version nodes --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e6aa928..98bfd94 100644 --- a/composer.json +++ b/composer.json @@ -76,5 +76,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.12" + "version": "4.1.14" } From e6f03c7d0d3740423c2dff09457a0daf2b1803af Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:18:03 +0200 Subject: [PATCH 72/73] chore: missing "v" prefix in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 98bfd94..39a8ae8 100644 --- a/composer.json +++ b/composer.json @@ -76,5 +76,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.14" + "version": "v4.1.15" } From 1081f99a02ef35c42779b0dd9f532ca0bddf2781 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Jun 2025 16:56:47 +0200 Subject: [PATCH 73/73] ci: remove version from composer to avoid release side effects (#7196) --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 39a8ae8..9df93fc 100644 --- a/composer.json +++ b/composer.json @@ -75,6 +75,5 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ], - "version": "v4.1.15" + ] }