diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..801f208 --- /dev/null +++ b/.gitattributes @@ -0,0 +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!" diff --git a/BackwardCompatibleSchemaFactory.php b/BackwardCompatibleSchemaFactory.php new file mode 100644 index 0000000..678ea8f --- /dev/null +++ b/BackwardCompatibleSchemaFactory.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema; + +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'])) { + continue; + } + + foreach ((array) $property['type'] as $type) { + if ('integer' !== $type && 'number' !== $type) { + continue; + } + + if (isset($property['exclusiveMinimum'])) { + $property['minimum'] = $property['exclusiveMinimum']; + $property['exclusiveMinimum'] = true; + } + if (isset($property['exclusiveMaximum'])) { + $property['maximum'] = $property['exclusiveMaximum']; + $property['exclusiveMaximum'] = true; + } + + break; + } + } + } + + return $schema; + } + + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void + { + if ($this->decorated instanceof SchemaFactoryAwareInterface) { + $this->decorated->setSchemaFactory($schemaFactory); + } + } +} diff --git a/Command/JsonSchemaGenerateCommand.php b/Command/JsonSchemaGenerateCommand.php index 708c83d..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,9 +30,9 @@ * * @author Jacques Lefebvre */ +#[AsCommand(name: 'api:json-schema:generate')] final class JsonSchemaGenerateCommand extends Command { - // @noRector \Rector\Php81\Rector\Property\ReadOnlyPropertyRector private array $formats; public function __construct(private readonly SchemaFactoryInterface $schemaFactory, array $formats) @@ -51,7 +52,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 +71,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; } @@ -91,9 +92,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - - public static function getDefaultName(): string - { - return 'api:json-schema:generate'; - } } diff --git a/DefinitionNameFactory.php b/DefinitionNameFactory.php new file mode 100644 index 0000000..29838f2 --- /dev/null +++ b/DefinitionNameFactory.php @@ -0,0 +1,91 @@ + + * + * 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; + + private const GLUE = '.'; + private array $prefixCache = []; + + public function __construct(private ?array $distinctFormats = null) + { + if ($distinctFormats) { + trigger_deprecation('api-platform/json-schema', '4.2', 'The distinctFormats argument is deprecated and will be removed in 5.0.'); + } + } + + 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 = $this->createPrefixFromClass($className); + } + + if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { + $parts = explode('\\', $inputOrOutputClass); + $shortName = end($parts); + $prefix .= self::GLUE.$shortName; + } + + // TODO: remove in 5.0 + $v = $this->distinctFormats ? ($this->distinctFormats[$format] ?? false) : true; + + if ('json' !== $format && $v) { + // JSON is the default, and so isn't included in the definition name + $prefix .= self::GLUE.$format; + } + + $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null; + 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; + } + + return $this->encodeDefinitionName($name); + } + + 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; + } +} 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/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php new file mode 100644 index 0000000..ecd549b --- /dev/null +++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -0,0 +1,570 @@ + + * + * 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\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; +use Doctrine\Common\Collections\ArrayCollection; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; +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]; + } + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + return $propertyMetadata->withSchema($this->getLegacyTypeSchema($propertyMetadata, $propertySchema, $resourceClass, $property, $link)); + } + + return $propertyMetadata->withSchema($this->getTypeSchema($propertyMetadata, $propertySchema, $link)); + } + + private function getTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, ?bool $link): array + { + $type = $propertyMetadata->getNativeType(); + + $className = null; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; + $isResourceClass = $type?->isSatisfiedBy($typeIsResourceClass); + + if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) && !$className) { + $propertySchema['readOnly'] = true; + } + + if (!\array_key_exists('default', $propertySchema) && null !== ($default = $propertyMetadata->getDefault()) && false === (\is_array($default) && empty($default)) && !$isResourceClass) { + if ($default instanceof \BackedEnum) { + $default = $default->value; + } + $propertySchema['default'] = $default; + } + + if (!\array_key_exists('example', $propertySchema) && null !== ($example = $propertyMetadata->getExample()) && false === (\is_array($example) && empty($example))) { + $propertySchema['example'] = $example; + } + + $hasType = $this->getSchemaValue($propertySchema, 'type') || $this->getSchemaValue($propertyMetadata->getJsonSchemaContext() ?? [], 'type') || $this->getSchemaValue($propertyMetadata->getOpenapiContext() ?? [], 'type'); + $hasRef = $this->getSchemaValue($propertySchema, '$ref') || $this->getSchemaValue($propertyMetadata->getJsonSchemaContext() ?? [], '$ref') || $this->getSchemaValue($propertyMetadata->getOpenapiContext() ?? [], '$ref'); + + // never override the following keys if at least one is already set or if there's a custom openapi context + if ($hasType || $hasRef || !$type) { + return $propertySchema; + } + + if ($type instanceof CollectionType && null !== $propertyMetadata->getUriTemplate()) { + $type = $type->getCollectionValueType(); + } + + return $propertySchema + $this->getJsonSchemaFromType($type, $link); + } + + /** + * Applies nullability rules to a generated JSON schema based on the original type's nullability. + * + * @param array $schema the base JSON schema generated for the non-null type + * @param bool $isNullable whether the original type allows null + * + * @return array the JSON schema with nullability applied + */ + private function applyNullability(array $schema, bool $isNullable): array + { + if (!$isNullable) { + return $schema; + } + + if (isset($schema['type']) && 'null' === $schema['type'] && 1 === \count($schema)) { + return $schema; + } + + if (isset($schema['anyOf']) && \is_array($schema['anyOf'])) { + $hasNull = false; + foreach ($schema['anyOf'] as $anyOfSchema) { + if (isset($anyOfSchema['type']) && 'null' === $anyOfSchema['type']) { + $hasNull = true; + break; + } + } + if (!$hasNull) { + $schema['anyOf'][] = ['type' => 'null']; + } + + return $schema; + } + + if (isset($schema['type'])) { + $currentType = $schema['type']; + $schema['type'] = \is_array($currentType) ? array_merge($currentType, ['null']) : [$currentType, 'null']; + + if (isset($schema['enum'])) { + $schema['enum'][] = null; + + return $schema; + } + + return $schema; + } + + return ['anyOf' => [$schema, ['type' => 'null']]]; + } + + /** + * Converts a TypeInfo Type into a JSON Schema definition array. + * + * @return array + */ + private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): array + { + $isNullable = $type->isNullable(); + + if ($type instanceof UnionType) { + $subTypes = array_filter($type->getTypes(), fn ($t) => !($t instanceof BuiltinType && $t->isIdentifiedBy(TypeIdentifier::NULL))); + + foreach ($subTypes as $t) { + $s = $this->getJsonSchemaFromType($t, $readableLink); + // We can not find what type this is, let it be computed at runtime by the SchemaFactory + if (($s['type'] ?? null) === Schema::UNKNOWN_TYPE) { + return $s; + } + } + + $schemas = array_map(fn ($t) => $this->getJsonSchemaFromType($t, $readableLink), $subTypes); + + if (0 === \count($schemas)) { + $schema = []; + } elseif (1 === \count($schemas)) { + $schema = current($schemas); + } else { + $schema = ['anyOf' => $schemas]; + } + + return $this->applyNullability($schema, $isNullable); + } + + if ($type instanceof IntersectionType) { + $schemas = []; + foreach ($type->getTypes() as $t) { + while ($t instanceof WrappingTypeInterface) { + $t = $t->getWrappedType(); + } + + $subSchema = $this->getJsonSchemaFromType($t, $readableLink); + if (!empty($subSchema)) { + $schemas[] = $subSchema; + } + } + + return $this->applyNullability(['allOf' => $schemas], $isNullable); + } + + if ($type instanceof CollectionType) { + $valueType = $type->getCollectionValueType(); + $valueSchema = $this->getJsonSchemaFromType($valueType, $readableLink); + $keyType = $type->getCollectionKeyType(); + + // Associative array (string keys) + if ($keyType->isSatisfiedBy(fn (Type $t) => $t instanceof BuiltinType && $t->isIdentifiedBy(TypeIdentifier::INT))) { + $schema = [ + 'type' => 'array', + 'items' => $valueSchema, + ]; + } else { // List (int keys) + $schema = [ + 'type' => 'object', + 'additionalProperties' => $valueSchema, + ]; + } + + return $this->applyNullability($schema, $isNullable); + } + + if ($type instanceof ObjectType) { + $schema = $this->getClassSchemaDefinition($type->getClassName(), $readableLink); + + return $this->applyNullability($schema, $isNullable); + } + + if ($type instanceof BuiltinType) { + $schema = match ($type->getTypeIdentifier()) { + TypeIdentifier::INT => ['type' => 'integer'], + TypeIdentifier::FLOAT => ['type' => 'number'], + TypeIdentifier::BOOL => ['type' => 'boolean'], + TypeIdentifier::TRUE => ['type' => 'boolean', 'const' => true], + TypeIdentifier::FALSE => ['type' => 'boolean', 'const' => false], + TypeIdentifier::STRING => ['type' => 'string'], + TypeIdentifier::ARRAY => ['type' => 'array', 'items' => []], + TypeIdentifier::ITERABLE => ['type' => 'array', 'items' => []], + TypeIdentifier::OBJECT => ['type' => 'object'], + TypeIdentifier::RESOURCE => ['type' => 'string'], + TypeIdentifier::CALLABLE => ['type' => 'string'], + default => ['type' => 'null'], + }; + + return $this->applyNullability($schema, $isNullable); + } + + return ['type' => Schema::UNKNOWN_TYPE]; + } + + /** + * Gets the JSON Schema definition for a class. + */ + private function getClassSchemaDefinition(?string $className, ?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']; + } + + $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'; + + return ['type' => $type, 'enum' => $enumCases]; + } + + if (false === $readableLink && $isResourceClass) { + return [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => 'https://example.com/', + ]; + } + + return ['type' => Schema::UNKNOWN_TYPE]; + } + + private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, string $resourceClass, string $property, ?bool $link): array + { + $types = $propertyMetadata->getBuiltinTypes() ?? []; + $className = ($types[0] ?? null)?->getClassName() ?? null; + + if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) && !$className) { + $propertySchema['readOnly'] = true; + } + + if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!$className || !$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; + } + + // 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) + || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? []) + ) { + return $propertySchema; + } + + if ($propertyMetadata->getUriTemplate()) { + return $propertySchema + [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => 'https://example.com/', + ]; + } + + $valueSchema = []; + foreach ($types as $type) { + // Temp fix for https://github.com/symfony/symfony/pull/52699 + if (ArrayCollection::class === $type->getClassName()) { + $type = new LegacyType($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; + } else { + $keyType = null; + $valueType = $type; + } + + if (null === $valueType) { + $builtinType = 'string'; + $className = null; + } else { + $builtinType = $valueType->getBuiltinType(); + $className = $valueType->getClassName(); + } + + if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { + $keyType = null; + $isCollection = false; + } + + $propertyType = $this->getLegacyType(new LegacyType($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); + if (!\in_array($propertyType, $valueSchema, true)) { + $valueSchema[] = $propertyType; + } + } + + if (1 === \count($valueSchema)) { + return $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 $propertySchema + [$composition => $valueSchema]; + } + + private function getLegacyType(LegacyType $type, ?bool $readableLink = null): array + { + if (!$type->isCollection()) { + return $this->addNullabilityToTypeDefinition($this->legacyTypeToArray($type, $readableLink), $type); + } + + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getLegacyType($subType, $readableLink), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getLegacyType($subType, $readableLink), + ], $type); + } + + private function legacyTypeToArray(LegacyType $type, ?bool $readableLink = null): array + { + return match ($type->getBuiltinType()) { + LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], + LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + LegacyType::BUILTIN_TYPE_OBJECT => $this->getLegacyClassType($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. + * + * @throws PropertyNotFoundException + * + * @return array + */ + private function getLegacyClassType(?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', + ]; + } + + $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'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + if (false === $readableLink && $isResourceClass) { + return [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => 'https://example.com/', + ]; + } + + // When this is set, we compute the schema at SchemaFactory::buildPropertySchema as it + // will end up being a $ref to another class schema, we don't have enough informations here + return ['type' => Schema::UNKNOWN_TYPE]; + } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType $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'], + ]]; + } + + private function getSchemaValue(array $schema, string $key): array|string|null + { + if (isset($schema['items'])) { + $schema = $schema['items']; + } + + return $schema[$key] ?? $schema['allOf'][0][$key] ?? $schema['anyOf'][0][$key] ?? $schema['oneOf'][0][$key] ?? null; + } +} 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). diff --git a/ResourceMetadataTrait.php b/ResourceMetadataTrait.php new file mode 100644 index 0000000..7198882 --- /dev/null +++ b/ResourceMetadataTrait.php @@ -0,0 +1,110 @@ + + * + * 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, ?string $format = null): 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, $forceSubschema ? null : $format); + } + + // 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, $format); + } + + return $operation; + } + + private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation, ?string $format = null): Operation + { + $lookForCollection = $operation instanceof CollectionOperationInterface; + // Find the operation and use the first one that matches criterias + foreach ($resourceMetadataCollection as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() ?? [] as $op) { + if (!$lookForCollection && $op instanceof CollectionOperationInterface) { + continue; + } + + if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) { + $operation = $op; + break 2; + } + + if ($format && Schema::TYPE_OUTPUT === $type && \array_key_exists($format, $op->getOutputFormats() ?? [])) { + $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/Schema.php b/Schema.php index 4531649..a6ea1d4 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) { @@ -122,7 +123,10 @@ private function removeDefinitionKeyPrefix(string $definitionKey): string { // strlen('#/definitions/') = 14 // strlen('#/components/schemas/') = 21 - $prefix = self::VERSION_OPENAPI === $this->version ? 21 : 14; + $prefix = match ($this->version) { + self::VERSION_OPENAPI => 21, + default => 14, + }; return substr($definitionKey, $prefix); } diff --git a/SchemaFactory.php b/SchemaFactory.php index 53de46d..f5a19a7 100644 --- a/SchemaFactory.php +++ b/SchemaFactory.php @@ -13,78 +13,86 @@ namespace ApiPlatform\JsonSchema; +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\PropertyInfo\Type; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * {@inheritdoc} * * @author Kévin Dunglas */ -final class SchemaFactory implements SchemaFactoryInterface +final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { - use ResourceClassInfoTrait; - private array $distinctFormats = []; + use ResourceMetadataTrait; + use SchemaUriPrefixTrait; + 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(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { + if (!$definitionNameFactory) { + $this->definitionNameFactory = new DefinitionNameFactory($distinctFormats); + } + $this->resourceMetadataFactory = $resourceMetadataFactory; $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} */ - 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(); - 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, $format); + $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'; } - 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 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; } if (!isset($schema['$ref']) && !isset($schema['type'])) { - $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName; + $ref = $this->getSchemaUriPrefix($version).$definitionName; if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) { $schema['type'] = 'array'; $schema['items'] = ['$ref' => $ref]; @@ -102,7 +110,9 @@ public function buildSchema(string $className, string $format = 'json', string $ /** @var \ArrayObject $definition */ $definition = new \ArrayObject(['type' => 'object']); $definitions[$definitionName] = $definition; - $definition['description'] = $operation ? ($operation->getDescription() ?? '') : ''; + if ($description = $operation?->getDescription()) { + $definition['description'] = $description; + } // additionalProperties are allowed by default, so it does not need to be set explicitly, unless allow_extra_attributes is false // See https://json-schema.org/understanding-json-schema/reference/object.html#properties @@ -113,8 +123,6 @@ public function buildSchema(string $className, string $format = 'json', string $ // see https://github.com/json-schema-org/json-schema-spec/pull/737 if (Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason()) { $definition['deprecated'] = true; - } else { - $definition['deprecated'] = false; } // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it @@ -123,10 +131,11 @@ 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()) { + + if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { continue; } @@ -135,16 +144,22 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['required'][] = $normalizedPropertyName; } - $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format); + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->buildLegacyPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); + } else { + $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 + /** + * Builds the JSON Schema for a property using the legacy PropertyInfo component. + */ + private function buildLegacyPropertySchema(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,189 +171,235 @@ 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; + // @see https://github.com/api-platform/core/issues/6299 + if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) { + unset($propertySchema['type']); } - $deprecationReason = $propertyMetadata->getDeprecationReason(); + $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); - // 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]; + return; } - // TODO: 3.0 support multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // 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; + + $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType + || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)) + || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null)); - if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault()) && (null === $type?->getClassName() || !$this->isResourceClass($type->getClassName()))) { - if ($default instanceof \BackedEnum) { - $default = $default->value; + // Scalar properties + 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) + ) + ) { + if (isset($propertySchema['$ref'])) { + unset($propertySchema['type']); } - $propertySchema['default'] = $default; - } - if (!isset($propertySchema['example']) && !empty($example = $propertyMetadata->getExample())) { - $propertySchema['example'] = $example; - } + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - if (!isset($propertySchema['example']) && isset($propertySchema['default'])) { - $propertySchema['example'] = $propertySchema['default']; + return; } - $valueSchema = []; - if (null !== $type) { - if ($isCollection = $type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; + // 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(); + $refs = []; + $isNullable = null; + + foreach ($types as $type) { + $subSchema = new Schema($version); + $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema + + $isCollection = $type->isCollection(); + if ($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) { + continue; + } + + $subSchemaFactory = $this->schemaFactory ?: $this; + $subSchema = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + if (!isset($subSchema['$ref'])) { + continue; } - $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema); + 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) { + $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; + $propertySchema[$key]['$ref'] = $subSchema['$ref']; + unset($propertySchema[$key]['type']); + break; + } + + $refs[] = ['$ref' => $subSchema['$ref']]; + $isNullable = $isNullable ?? $type->isNullable(); } - if (\array_key_exists('type', $propertySchema) && \array_key_exists('$ref', $valueSchema)) { - $propertySchema = new \ArrayObject($propertySchema); - } else { - $propertySchema = new \ArrayObject($propertySchema + $valueSchema); + if ($isNullable) { + $refs[] = ['type' => 'null']; + } + + $c = \count($refs); + if ($c > 1) { + $propertySchema['anyOf'] = $refs; + unset($propertySchema['type']); + } elseif (1 === $c) { + $propertySchema['$ref'] = $refs[0]['$ref']; + unset($propertySchema['type']); } - $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 + private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void { - if ($operation) { - $prefix = $operation->getShortName(); + $version = $schema->getVersion(); + if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { + $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); + } else { + $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext(); } - if (!isset($prefix)) { - $prefix = (new \ReflectionClass($className))->getShortName(); - } + $propertySchema = array_merge( + $propertyMetadata->getSchema() ?? [], + $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); - if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { - $shortName = $this->getShortClassName($inputOrOutputClass); - $prefix .= '.'.$shortName; + return; } - if (isset($this->distinctFormats[$format])) { - // JSON is the default, and so isn't included in the definition name - $prefix .= '.'.$format; + $type = $propertyMetadata->getNativeType(); + + // Type is defined in an allOf, anyOf, or oneOf + $propertySchemaType = $this->getSchemaValue($propertySchema, 'type'); + $currentRef = $this->getSchemaValue($propertySchema, '$ref'); + $isSchemaDefined = null !== ($currentRef ?? $this->getSchemaValue($propertySchema, 'format') ?? $this->getSchemaValue($propertySchema, 'enum')); + if (!$isSchemaDefined && Schema::UNKNOWN_TYPE !== $propertySchemaType) { + $isSchemaDefined = true; } - $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; + // Check if the type is considered "unknown" by SchemaPropertyMetadataFactory + if (isset($propertySchema['additionalProperties']['type']) && Schema::UNKNOWN_TYPE === $propertySchema['additionalProperties']['type']) { + $isSchemaDefined = false; } - return $this->encodeDefinitionName($name); - } + if ($isSchemaDefined && Schema::UNKNOWN_TYPE !== $propertySchemaType) { + // If schema is defined and not marked as unknown, or if no type info exists, return early + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - private function encodeDefinitionName(string $name): string - { - return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name); - } + return; + } - 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(); - } + if (Schema::UNKNOWN_TYPE === $propertySchemaType) { + $propertySchema = []; + } - $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); + // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) + // complete property schema with resource reference ($ref) if it's related to an object/resource + $refs = []; + $isNullable = $type?->isNullable() ?? false; + + // TODO: refactor this with TypeInfo we shouldn't have to loop like this, the below code handles object refs + if ($type) { + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + if ($t instanceof BuiltinType && TypeIdentifier::NULL === $t->getTypeIdentifier()) { + continue; } - } - } - $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); + $valueType = $t; + $isCollection = $t instanceof CollectionType; - if (null === $outputClass) { - // input or output disabled - return null; - } + if ($isCollection) { + $valueType = TypeHelper::getCollectionValueType($t); + } - return [ - $operation, - $serializerContext ?? $this->getSerializerContext($operation, $type), - $this->getValidationGroups($operation), - $outputClass, - ]; - } + if (!$valueType instanceof ObjectType) { + continue; + } - 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; + $className = $valueType->getClassName(); + $subSchemaInstance = new Schema($version); + $subSchemaInstance->setDefinitions($schema->getDefinitions()); + $subSchemaFactory = $this->schemaFactory ?: $this; + $subSchemaResult = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchemaInstance, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + if (!isset($subSchemaResult['$ref'])) { + continue; } - if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) { - $operation = $op; - break 2; + if (false === $propertyMetadata->getGenId()) { + $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext); + if (isset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id'])) { + unset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id']); + } } + + if ($isCollection) { + $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; + if (!isset($propertySchema['type'])) { + $propertySchema['type'] = 'array'; + } + + if (!isset($propertySchema[$key]) || !\is_array($propertySchema[$key])) { + $propertySchema[$key] = []; + } + $propertySchema[$key] = ['$ref' => $subSchemaResult['$ref']]; + $refs = []; + break; + } + + $refs[] = ['$ref' => $subSchemaResult['$ref']]; } } - return $operation; - } + if (!empty($refs)) { + if ($isNullable) { + $refs[] = ['type' => 'null']; + } - private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array - { - return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []); + if (($c = \count($refs)) > 1) { + $propertySchema = ['anyOf' => $refs]; + } elseif (1 === $c) { + $propertySchema = ['$ref' => $refs[0]['$ref']]; + } + } + + if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) && !isset($propertySchema['$ref'])) { + $propertySchema['readOnly'] = true; + } + + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); } private function getValidationGroups(Operation $operation): array @@ -351,7 +412,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 */ @@ -375,13 +436,24 @@ 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; } - private function getShortClassName(string $fullyQualifiedName): string + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void + { + $this->schemaFactory = $schemaFactory; + } + + private function getSchemaValue(array $schema, string $key): array|string|null { - $parts = explode('\\', $fullyQualifiedName); + if (isset($schema['items'])) { + $schema = $schema['items']; + } - return end($parts); + return $schema[$key] ?? $schema['allOf'][0][$key] ?? $schema['anyOf'][0][$key] ?? $schema['oneOf'][0][$key] ?? null; } } 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/SchemaFactoryInterface.php b/SchemaFactoryInterface.php index 3248156..64b2707 100644 --- a/SchemaFactoryInterface.php +++ b/SchemaFactoryInterface.php @@ -22,8 +22,10 @@ */ interface SchemaFactoryInterface { + public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; + /** * 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/SchemaUriPrefixTrait.php b/SchemaUriPrefixTrait.php new file mode 100644 index 0000000..de5efd9 --- /dev/null +++ b/SchemaUriPrefixTrait.php @@ -0,0 +1,28 @@ + + * + * 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; + +/** + * @internal + */ +trait SchemaUriPrefixTrait +{ + public function getSchemaUriPrefix(string $version): string + { + return match ($version) { + Schema::VERSION_OPENAPI => '#/components/schemas/', + default => '#/definitions/', + }; + } +} 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); + } +} diff --git a/Tests/DefinitionNameFactoryTest.php b/Tests/DefinitionNameFactoryTest.php new file mode 100644 index 0000000..e50764e --- /dev/null +++ b/Tests/DefinitionNameFactoryTest.php @@ -0,0 +1,97 @@ + + * + * 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\DefinitionNameFactory; +use ApiPlatform\JsonSchema\SchemaFactory; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DtoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; + +final class DefinitionNameFactoryTest extends TestCase +{ + public static function providerDefinitions(): iterable + { + yield ['Dummy', Dummy::class, 'json']; + yield ['Dummy.jsonapi', Dummy::class, 'jsonapi']; + yield ['Dummy.jsonhal', Dummy::class, 'jsonhal']; + yield ['Dummy.jsonld', Dummy::class, 'jsonld']; + + yield ['Dummy.DtoOutput', Dummy::class, 'json', DtoOutput::class]; + yield ['Dummy.DtoOutput.jsonapi', Dummy::class, 'jsonapi', DtoOutput::class]; + yield ['Dummy.DtoOutput.jsonhal', Dummy::class, 'jsonhal', DtoOutput::class]; + yield ['Dummy.DtoOutput.jsonld', Dummy::class, 'jsonld', DtoOutput::class]; + + yield ['Bar', Dummy::class, 'json', null, new Get(shortName: 'Bar')]; + yield ['Bar.jsonapi', Dummy::class, 'jsonapi', null, new Get(shortName: 'Bar')]; + yield ['Bar.jsonhal', Dummy::class, 'jsonhal', null, new Get(shortName: 'Bar')]; + yield ['Bar.jsonld', Dummy::class, 'jsonld', null, new Get(shortName: 'Bar')]; + + yield ['Dummy-Baz', Dummy::class, 'json', null, null, [SchemaFactory::OPENAPI_DEFINITION_NAME => 'Baz']]; + yield ['Dummy.jsonapi-Baz', Dummy::class, 'jsonapi', null, null, [SchemaFactory::OPENAPI_DEFINITION_NAME => 'Baz']]; + yield ['Dummy.jsonhal-Baz', Dummy::class, 'jsonhal', null, null, [SchemaFactory::OPENAPI_DEFINITION_NAME => 'Baz']]; + yield ['Dummy.jsonld-Baz', Dummy::class, 'jsonld', null, null, [SchemaFactory::OPENAPI_DEFINITION_NAME => 'Baz']]; + + yield ['Dummy-read', Dummy::class, 'json', null, null, [AbstractNormalizer::GROUPS => ['read']]]; + yield ['Dummy.jsonapi-read', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::GROUPS => ['read']]]; + yield ['Dummy.jsonhal-read', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::GROUPS => ['read']]]; + yield ['Dummy.jsonld-read', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::GROUPS => ['read']]]; + + yield ['Dummy-read_write', Dummy::class, 'json', null, null, [AbstractNormalizer::GROUPS => ['read', 'write']]]; + yield ['Dummy.jsonapi-read_write', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::GROUPS => ['read', 'write']]]; + yield ['Dummy.jsonhal-read_write', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::GROUPS => ['read', 'write']]]; + yield ['Dummy.jsonld-read_write', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::GROUPS => ['read', 'write']]]; + + yield ['Bar.DtoOutput-read_write', Dummy::class, 'json', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::GROUPS => ['read', 'write']]]; + yield ['Bar.DtoOutput.jsonapi-read_write', Dummy::class, 'jsonapi', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::GROUPS => ['read', 'write']]]; + yield ['Bar.DtoOutput.jsonhal-read_write', Dummy::class, 'jsonhal', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::GROUPS => ['read', 'write']]]; + yield ['Bar.DtoOutput.jsonld-read_write', Dummy::class, 'jsonld', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::GROUPS => ['read', 'write']]]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('providerDefinitions')] + public function testCreate(string $expected, string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): void + { + $definitionNameFactory = new DefinitionNameFactory(); + + static::assertSame($expected, $definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext)); + } + + public function testCreateDifferentPrefixesForClassesWithTheSameShortName(): void + { + $definitionNameFactory = new DefinitionNameFactory(); + + self::assertEquals( + 'DummyClass.jsonapi', + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceA\Module\DummyClass::class, 'jsonapi') + ); + + self::assertEquals( + 'Module.DummyClass.jsonapi', + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceB\Module\DummyClass::class, 'jsonapi') + ); + + self::assertEquals( + 'NamespaceC.Module.DummyClass.jsonapi', + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceC\Module\DummyClass::class, 'jsonapi') + ); + + self::assertEquals( + 'DummyClass.jsonhal', + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceA\Module\DummyClass::class, 'jsonhal') + ); + } +} 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/Tests/Fixtures/DefinitionNameFactory/NamespaceA/Module/DummyClass.php b/Tests/Fixtures/DefinitionNameFactory/NamespaceA/Module/DummyClass.php new file mode 100644 index 0000000..6663f66 --- /dev/null +++ b/Tests/Fixtures/DefinitionNameFactory/NamespaceA/Module/DummyClass.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures\DefinitionNameFactory\NamespaceA\Module; + +class DummyClass +{ +} diff --git a/Tests/Fixtures/DefinitionNameFactory/NamespaceB/Module/DummyClass.php b/Tests/Fixtures/DefinitionNameFactory/NamespaceB/Module/DummyClass.php new file mode 100644 index 0000000..5b043d1 --- /dev/null +++ b/Tests/Fixtures/DefinitionNameFactory/NamespaceB/Module/DummyClass.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures\DefinitionNameFactory\NamespaceB\Module; + +class DummyClass +{ +} diff --git a/Tests/Fixtures/DefinitionNameFactory/NamespaceC/Module/DummyClass.php b/Tests/Fixtures/DefinitionNameFactory/NamespaceC/Module/DummyClass.php new file mode 100644 index 0000000..1790061 --- /dev/null +++ b/Tests/Fixtures/DefinitionNameFactory/NamespaceC/Module/DummyClass.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures\DefinitionNameFactory\NamespaceC\Module; + +class DummyClass +{ +} 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/DummyWithCustomOpenApiContext.php b/Tests/Fixtures/DummyWithCustomOpenApiContext.php new file mode 100644 index 0000000..c676ea8 --- /dev/null +++ b/Tests/Fixtures/DummyWithCustomOpenApiContext.php @@ -0,0 +1,39 @@ + + * + * 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; + + #[ApiProperty(openapiContext: ['description' => 'My description'])] + public bool $foo; + + #[ApiProperty(openapiContext: ['iris' => 'https://schema.org/Date'])] + public \DateTimeImmutable $bar; +} 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/NotAResource.php b/Tests/Fixtures/NotAResource.php index a3d2292..263c72b 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,14 +23,10 @@ class NotAResource { public function __construct( - /** - * @Groups("contain_non_resource") - */ + #[Groups('contain_non_resource')] private $foo, - /** - * @Groups("contain_non_resource") - */ - private $bar + #[Groups('contain_non_resource')] + private $bar, ) { } diff --git a/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php new file mode 100644 index 0000000..d28cf68 --- /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..abbbcc1 --- /dev/null +++ b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -0,0 +1,143 @@ + + * + * 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\DummyWithCustomOpenApiContext; +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\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type; + +class SchemaPropertyMetadataFactoryTest extends TestCase +{ + #[IgnoreDeprecations] + public function testEnumLegacy(): void + { + $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty(builtinTypes: [new LegacyType(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()); + } + + public function testEnum(): void + { + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty(nativeType: Type::nullable(Type::enum(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()); + } + + #[IgnoreDeprecations] + public function testWithCustomOpenApiContextLegacy(): void + { + $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty( + builtinTypes: [new LegacyType(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()); + } + + public function testWithCustomOpenApiContext(): void + { + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty( + nativeType: Type::nullable(Type::enum(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()); + } + + #[IgnoreDeprecations] + public function testWithCustomOpenApiContextWithoutTypeDefinitionLegacy(): void + { + $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty( + openapiContext: ['description' => 'My description'], + builtinTypes: [new LegacyType(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 LegacyType(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()); + } + + public function testWithCustomOpenApiContextWithoutTypeDefinition(): void + { + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $apiProperty = new ApiProperty( + openapiContext: ['description' => 'My description'], + nativeType: Type::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'], + nativeType: Type::object(\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()); + } +} diff --git a/Tests/SchemaFactoryTest.php b/Tests/SchemaFactoryTest.php index 806db5d..873c6d0 100644 --- a/Tests/SchemaFactoryTest.php +++ b/Tests/SchemaFactoryTest.php @@ -13,12 +13,15 @@ namespace ApiPlatform\JsonSchema\Tests; +use ApiPlatform\JsonSchema\DefinitionNameFactory; 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; @@ -29,59 +32,143 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\TypeInfo\Type; class SchemaFactoryTest extends TestCase { use ProphecyTrait; - public function testBuildSchemaForNonResourceClass(): void + #[IgnoreDeprecations] + public function testBuildSchemaForNonResourceClassLegacy(): 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', - ]); + $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); + $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 LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new LegacyType(LegacyType::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 LegacyType(LegacyType::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); + + $definitionNameFactory = new DefinitionNameFactory(); + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + $resultSchema = $schemaFactory->buildSchema(NotAResource::class); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); + $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('foo', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['foo']); + $this->assertArrayNotHasKey('default', $definitions[$rootDefinitionKey]['properties']['foo']); + $this->assertArrayNotHasKey('example', $definitions[$rootDefinitionKey]['properties']['foo']); + $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['foo']['type']); + $this->assertArrayHasKey('bar', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['bar']); + $this->assertArrayHasKey('default', $definitions[$rootDefinitionKey]['properties']['bar']); + $this->assertArrayHasKey('example', $definitions[$rootDefinitionKey]['properties']['bar']); + $this->assertSame('integer', $definitions[$rootDefinitionKey]['properties']['bar']['type']); + $this->assertSame('default_bar', $definitions[$rootDefinitionKey]['properties']['bar']['default']); + $this->assertSame('example_bar', $definitions[$rootDefinitionKey]['properties']['bar']['example']); + + $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); + $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['default']); + $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); + } + + public function testBuildSchemaForNonResourceClass(): void + { $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()) + ->withNativeType(Type::string()) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withNativeType(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()) + ->withNativeType(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()); + $definitionNameFactory = new DefinitionNameFactory(); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); $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']); @@ -109,22 +196,151 @@ public function testBuildSchemaForNonResourceClass(): void $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); } - public function testBuildSchemaWithSerializerGroups(): void + #[IgnoreDeprecations] + public function testBuildSchemaForNonResourceClassWithUnionIntersectTypesLegacy(): void + { + $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); + $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 LegacyType(LegacyType::BUILTIN_TYPE_STRING, nullable: true)]) + ->withReadable(true) + ->withSchema(['type' => ['string', 'null']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, nullable: true), new LegacyType(LegacyType::BUILTIN_TYPE_INT, nullable: true), new LegacyType(LegacyType::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 LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, class: Serializable::class), new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, class: DummyResourceInterface::class)]) + ->withReadable(true) + ->withSchema(['type' => 'object']) + ); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); + + $definitionNameFactory = new DefinitionNameFactory(); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(NotAResourceWithUnionIntersectTypes::class))->getShortName(), $rootDefinitionKey); + $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 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()) + ->withNativeType(Type::nullable(Type::string())) + ->withReadable(true) + ->withSchema(['type' => ['string', 'null']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withNativeType(Type::union(Type::string(), Type::int(), Type::float(), Type::null())) + ->withReadable(true) + ->withSchema(['oneOf' => [ + ['type' => ['string', 'null']], + ['type' => ['integer', 'null']], + ]]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'intersectType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withNativeType(Type::intersection(Type::object(Serializable::class), Type::object(DummyResourceInterface::class))) + ->withReadable(true) + ->withSchema(['type' => 'object']) + ); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); + + $definitionNameFactory = new DefinitionNameFactory(); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(NotAResourceWithUnionIntersectTypes::class))->getShortName(), $rootDefinitionKey); + $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']); + } + + #[IgnoreDeprecations] + public function testBuildSchemaWithSerializerGroupsLegacy(): void + { + $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $operation = (new Put())->withName('put')->withNormalizationContext([ @@ -144,22 +360,45 @@ 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 LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new LegacyType(LegacyType::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()); + $definitionNameFactory = new DefinitionNameFactory(); + + $schemaFactory = new SchemaFactory( + 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(); $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']); @@ -178,55 +417,176 @@ public function testBuildSchemaWithSerializerGroups(): void $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); } - public function testBuildSchemaForAssociativeArray(): void + public function testBuildSchemaWithSerializerGroups(): void + { + $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $operation = (new Put())->withName('put')->withNormalizationContext([ + 'groups' => 'overridden_operation_dummy_put', + AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false, + ])->withShortName($shortName)->withValidationContext(['groups' => ['validation_groups_dummy_put']]); + $resourceMetadataFactoryProphecy->create(OverriddenOperationDummy::class) + ->willReturn( + new ResourceMetadataCollection(OverriddenOperationDummy::class, [ + (new ApiResource())->withOperations(new Operations(['put' => $operation])), + ]) + ); + + $serializerGroup = 'custom_operation_dummy'; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $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()) + ->withNativeType(Type::string()) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withNativeType(Type::string()) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withNativeType(Type::enum(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); + + $definitionNameFactory = new DefinitionNameFactory(); + + $schemaFactory = new SchemaFactory( + 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(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(OverriddenOperationDummy::class))->getShortName().'-'.$serializerGroup, $rootDefinitionKey); + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); + $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); + $this->assertFalse($definitions[$rootDefinitionKey]['additionalProperties']); + $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); + $this->assertArrayHasKey('alias', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['alias']); + $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['alias']['type']); + $this->assertArrayHasKey('description', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['description']); + $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['description']['type']); + $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayNotHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayNotHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); + } + + #[IgnoreDeprecations] + public function testBuildSchemaForAssociativeArrayLegacy(): 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, - ]); + $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); + $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 LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'array', 'items' => ['string', 'int']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_STRING), new LegacyType(LegacyType::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'object', 'additionalProperties' => 'string']) + ); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); + + $definitionNameFactory = new DefinitionNameFactory(); + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + $resultSchema = $schemaFactory->buildSchema(NotAResource::class); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); + $this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['foo']); + $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]['properties']['foo']); + $this->assertSame('array', $definitions[$rootDefinitionKey]['properties']['foo']['type']); + $this->assertArrayHasKey('bar', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['bar']); + $this->assertArrayHasKey('additionalProperties', $definitions[$rootDefinitionKey]['properties']['bar']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['bar']['type']); + $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['bar']['additionalProperties']); + } + + public function testBuildSchemaForAssociativeArray(): void + { $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()) + ->withNativeType(Type::list(Type::string())) + ->withReadable(true) + ->withSchema(['type' => 'array', 'items' => ['string', 'int']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withNativeType(Type::dict(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()); + $definitionNameFactory = new DefinitionNameFactory(); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); $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..d60ab82 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); @@ -73,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)); 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 c3a94c5..0000000 --- a/TypeFactory.php +++ /dev/null @@ -1,200 +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} - * - * @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 ($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 795854d..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 4687122..168d437 100644 --- a/composer.json +++ b/composer.json @@ -24,17 +24,17 @@ } ], "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" + "php": ">=8.2", + "api-platform/metadata": "4.2.x-dev as dev-main", + "symfony/console": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.1", + "symfony/serializer": "^6.4 || ^7.0", + "symfony/type-info": "^7.3", + "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1" + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "11.5.x-dev" }, "autoload": { "psr-4": { @@ -56,16 +56,25 @@ }, "extra": { "branch-alias": { - "dev-main": "3.2.x-dev" + "dev-main": "4.2.x-dev", + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.4 || ^7.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, "repositories": [ { - "type": "path", - "url": "../Metadata" + "type": "vcs", + "url": "https://github.com/soyuka/phpunit" } ] } 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 b33b9e3..1987ee6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,23 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - - ./Tests - ./vendor - - + + + + + + + ./Tests/ + + + + + trigger_deprecation + + + ./ + + + ./Tests + ./vendor + + -