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
+
+
-