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/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); + } + } +} diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php new file mode 100644 index 0000000..f906f25 --- /dev/null +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -0,0 +1,292 @@ + + * + * 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\JsonSchema\Schema; +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 const JSON_SCHEMA_USER_DEFINED = 'user_defined_schema'; + + 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(); + } + } + + $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() ?? []; + + if (null !== $propertyMetadata->getUriTemplate() || (!\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']; + } + + // 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; + } + + if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { + $keyType = null; + $isCollection = false; + } + + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); + 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' : 'integer'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + if (true !== $readableLink && $this->isResourceClass($className)) { + return [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => 'https://example.com/', + ]; + } + + // 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]; + } + + /** + * @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/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 53de46d..42af8dd 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; @@ -24,7 +25,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; @@ -33,17 +33,22 @@ * * @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'; - 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) { + $this->typeFactory = $typeFactory; + } + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceClassResolver = $resourceClassResolver; } @@ -79,7 +84,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; } @@ -123,7 +129,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()) { @@ -135,16 +141,15 @@ 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(); - $swagger = Schema::VERSION_SWAGGER === $version; if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); } else { @@ -156,74 +161,86 @@ 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; + $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; } - $deprecationReason = $propertyMetadata->getDeprecationReason(); + $types = $propertyMetadata->getBuiltinTypes() ?? []; - // 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]; - } + // 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; - // TODO: 3.0 support multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType + || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)); - 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; - } + 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); - if (!isset($propertySchema['example']) && !empty($example = $propertyMetadata->getExample())) { - $propertySchema['example'] = $example; + return; } - if (!isset($propertySchema['example']) && isset($propertySchema['default'])) { - $propertySchema['example'] = $propertySchema['default']; - } + // 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) { + // 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; + } - $valueSchema = []; - if (null !== $type) { - if ($isCollection = $type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $isCollection = $type->isCollection(); + if ($isCollection) { $valueType = $type->getCollectionValueTypes()[0] ?? null; } else { - $keyType = null; $valueType = $type; } - if (null === $valueType) { - $builtinType = 'string'; - $className = null; + $className = $valueType?->getClassName(); + if (null === $className) { + continue; + } + + $subSchemaFactory = $this->schemaFactory ?: $this; + $subSchema = $subSchemaFactory->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']); + break; + } + + if ($type->isNullable()) { + $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; } else { - $builtinType = $valueType->getBuiltinType(); - $className = $valueType->getClassName(); + $propertySchema['$ref'] = $subSchema['$ref']; } - $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema); + 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 @@ -384,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; +} 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/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/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/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/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/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()); + } +} 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(); diff --git a/TypeFactory.php b/TypeFactory.php index c3a94c5..34f1f3f 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); diff --git a/composer.json b/composer.json index 4687122..e022cdd 100644 --- a/composer.json +++ b/composer.json @@ -26,15 +26,15 @@ "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", - "sebastian/comparator": "<5.0" + "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": { "psr-4": { @@ -59,7 +59,7 @@ "dev-main": "3.2.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.4" } }, "repositories": [