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..eebcf5d 100644
--- a/Command/JsonSchemaGenerateCommand.php
+++ b/Command/JsonSchemaGenerateCommand.php
@@ -31,7 +31,6 @@
*/
final class JsonSchemaGenerateCommand extends Command
{
- // @noRector \Rector\Php81\Rector\Property\ReadOnlyPropertyRector
private array $formats;
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, array $formats)
@@ -51,7 +50,7 @@ protected function configure(): void
->addArgument('resource', InputArgument::REQUIRED, 'The Fully Qualified Class Name (FQCN) of the resource')
->addOption('operation', null, InputOption::VALUE_REQUIRED, 'The operation name')
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The response format', (string) $this->formats[0])
- ->addOption('type', null, InputOption::VALUE_REQUIRED, sprintf('The type of schema to generate (%s or %s)', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT), Schema::TYPE_INPUT);
+ ->addOption('type', null, InputOption::VALUE_REQUIRED, \sprintf('The type of schema to generate (%s or %s)', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT), Schema::TYPE_INPUT);
}
/**
@@ -70,19 +69,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$type = $input->getOption('type');
if (!\in_array($type, [Schema::TYPE_INPUT, Schema::TYPE_OUTPUT], true)) {
- $io->error(sprintf('You can only use "%s" or "%s" for the "type" option', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT));
+ $io->error(\sprintf('You can only use "%s" or "%s" for the "type" option', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT));
return 1;
}
if (!\in_array($format, $this->formats, true)) {
- throw new InvalidOptionException(sprintf('The response format "%s" is not supported. Supported formats are : %s.', $format, implode(', ', $this->formats)));
+ throw new InvalidOptionException(\sprintf('The response format "%s" is not supported. Supported formats are : %s.', $format, implode(', ', $this->formats)));
}
- $schema = $this->schemaFactory->buildSchema($resource, $format, $type, $operation ? (new class() extends HttpOperation {})->withName($operation) : null);
+ $schema = $this->schemaFactory->buildSchema($resource, $format, $type, $operation ? (new class extends HttpOperation {})->withName($operation) : null);
if (!$schema->isDefined()) {
- $io->error(sprintf('There is no %s defined for the operation "%s" of the resource "%s".', $type, $operation, $resource));
+ $io->error(\sprintf('There is no %s defined for the operation "%s" of the resource "%s".', $type, $operation, $resource));
return 1;
}
diff --git a/DefinitionNameFactory.php b/DefinitionNameFactory.php
new file mode 100644
index 0000000..86076de
--- /dev/null
+++ b/DefinitionNameFactory.php
@@ -0,0 +1,64 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\JsonSchema;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
+use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
+
+final class DefinitionNameFactory implements DefinitionNameFactoryInterface
+{
+ use ResourceClassInfoTrait;
+
+ public function __construct(private ?array $distinctFormats)
+ {
+ }
+
+ public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string
+ {
+ if ($operation) {
+ $prefix = $operation->getShortName();
+ }
+
+ if (!isset($prefix)) {
+ $prefix = (new \ReflectionClass($className))->getShortName();
+ }
+
+ if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
+ $parts = explode('\\', $inputOrOutputClass);
+ $shortName = end($parts);
+ $prefix .= '.'.$shortName;
+ }
+
+ if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) {
+ // JSON is the default, and so isn't included in the definition name
+ $prefix .= '.'.$format;
+ }
+
+ $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null;
+ if ($definitionName) {
+ $name = \sprintf('%s-%s', $prefix, $definitionName);
+ } else {
+ $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
+ $name = $groups ? \sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
+ }
+
+ return $this->encodeDefinitionName($name);
+ }
+
+ private function encodeDefinitionName(string $name): string
+ {
+ return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
+ }
+}
diff --git a/DefinitionNameFactoryInterface.php b/DefinitionNameFactoryInterface.php
new file mode 100644
index 0000000..26de6f1
--- /dev/null
+++ b/DefinitionNameFactoryInterface.php
@@ -0,0 +1,33 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\JsonSchema;
+
+use ApiPlatform\Metadata\Operation;
+
+/**
+ * Factory for creating definition names for resources in a JSON Schema document.
+ *
+ * @author Gwendolen Lynch
+ */
+interface DefinitionNameFactoryInterface
+{
+ /**
+ * Creates a resource definition name.
+ *
+ * @param class-string $className
+ *
+ * @return string the definition name
+ */
+ public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string;
+}
diff --git a/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php
new file mode 100644
index 0000000..2be0459
--- /dev/null
+++ b/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php
@@ -0,0 +1,291 @@
+
+ *
+ * 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\Type;
+use Symfony\Component\Uid\Ulid;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * Build ApiProperty::schema.
+ */
+final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInterface
+{
+ use ResourceClassInfoTrait;
+
+ public const JSON_SCHEMA_USER_DEFINED = 'user_defined_schema';
+
+ public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null)
+ {
+ $this->resourceClassResolver = $resourceClassResolver;
+ }
+
+ public function create(string $resourceClass, string $property, array $options = []): ApiProperty
+ {
+ if (null === $this->decorated) {
+ $propertyMetadata = new ApiProperty();
+ } else {
+ try {
+ $propertyMetadata = $this->decorated->create($resourceClass, $property, $options);
+ } catch (PropertyNotFoundException) {
+ $propertyMetadata = new ApiProperty();
+ }
+ }
+
+ $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
+ // see AttributePropertyMetadataFactory
+ if (true === ($extraProperties[self::JSON_SCHEMA_USER_DEFINED] ?? false)) {
+ // schema seems to have been declared by the user: do not override nor complete user value
+ return $propertyMetadata;
+ }
+
+ $link = (($options['schema_type'] ?? null) === Schema::TYPE_INPUT) ? $propertyMetadata->isWritableLink() : $propertyMetadata->isReadableLink();
+ $propertySchema = $propertyMetadata->getSchema() ?? [];
+
+ if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) {
+ $propertySchema['readOnly'] = true;
+ }
+
+ if (!\array_key_exists('writeOnly', $propertySchema) && false === $propertyMetadata->isReadable()) {
+ $propertySchema['writeOnly'] = true;
+ }
+
+ if (!\array_key_exists('description', $propertySchema) && null !== ($description = $propertyMetadata->getDescription())) {
+ $propertySchema['description'] = $description;
+ }
+
+ // see https://github.com/json-schema-org/json-schema-spec/pull/737
+ if (!\array_key_exists('deprecated', $propertySchema) && null !== $propertyMetadata->getDeprecationReason()) {
+ $propertySchema['deprecated'] = true;
+ }
+
+ // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
+ // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
+ if (!\array_key_exists('externalDocs', $propertySchema) && null !== ($iri = $propertyMetadata->getTypes()[0] ?? null)) {
+ $propertySchema['externalDocs'] = ['url' => $iri];
+ }
+
+ $types = $propertyMetadata->getBuiltinTypes() ?? [];
+
+ if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) {
+ if ($default instanceof \BackedEnum) {
+ $default = $default->value;
+ }
+ $propertySchema['default'] = $default;
+ }
+
+ if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) {
+ $propertySchema['example'] = $example;
+ }
+
+ if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) {
+ $propertySchema['example'] = $propertySchema['default'];
+ }
+
+ // never override the following keys if at least one is already set or if there's a custom openapi context
+ if ([] === $types
+ || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
+ || ($propertyMetadata->getOpenapiContext() ?? false)
+ ) {
+ return $propertyMetadata->withSchema($propertySchema);
+ }
+
+ $valueSchema = [];
+ foreach ($types as $type) {
+ // Temp fix for https://github.com/symfony/symfony/pull/52699
+ if (ArrayCollection::class === $type->getClassName()) {
+ $type = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes());
+ }
+
+ if ($isCollection = $type->isCollection()) {
+ $keyType = $type->getCollectionKeyTypes()[0] ?? null;
+ $valueType = $type->getCollectionValueTypes()[0] ?? null;
+ } 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->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link);
+ if (!\in_array($propertyType, $valueSchema, true)) {
+ $valueSchema[] = $propertyType;
+ }
+ }
+
+ // only one builtInType detected (should be "type" or "$ref")
+ if (1 === \count($valueSchema)) {
+ return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]);
+ }
+
+ // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types
+ try {
+ $reflectionClass = new \ReflectionClass($resourceClass);
+ $reflectionProperty = $reflectionClass->getProperty($property);
+ $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf';
+ } catch (\ReflectionException) {
+ // cannot detect types
+ $composition = 'anyOf';
+ }
+
+ return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]);
+ }
+
+ private function getType(Type $type, ?bool $readableLink = null): array
+ {
+ if (!$type->isCollection()) {
+ return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type);
+ }
+
+ $keyType = $type->getCollectionKeyTypes()[0] ?? null;
+ $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false);
+
+ if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) {
+ return $this->addNullabilityToTypeDefinition([
+ 'type' => 'object',
+ 'additionalProperties' => $this->getType($subType, $readableLink),
+ ], $type);
+ }
+
+ return $this->addNullabilityToTypeDefinition([
+ 'type' => 'array',
+ 'items' => $this->getType($subType, $readableLink),
+ ], $type);
+ }
+
+ private function typeToArray(Type $type, ?bool $readableLink = null): array
+ {
+ return match ($type->getBuiltinType()) {
+ Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
+ Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'],
+ Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'],
+ Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink),
+ default => ['type' => 'string'],
+ };
+ }
+
+ /**
+ * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided.
+ *
+ * Note: if the class is not part of exceptions listed above, any class is considered as a resource.
+ */
+ private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array
+ {
+ if (null === $className) {
+ return ['type' => 'string'];
+ }
+
+ if (is_a($className, \DateTimeInterface::class, true)) {
+ return [
+ 'type' => 'string',
+ 'format' => 'date-time',
+ ];
+ }
+
+ if (is_a($className, \DateInterval::class, true)) {
+ return [
+ 'type' => 'string',
+ 'format' => 'duration',
+ ];
+ }
+
+ if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) {
+ return [
+ 'type' => 'string',
+ 'format' => 'uuid',
+ ];
+ }
+
+ if (is_a($className, Ulid::class, true)) {
+ return [
+ 'type' => 'string',
+ 'format' => 'ulid',
+ ];
+ }
+
+ if (is_a($className, \SplFileInfo::class, true)) {
+ return [
+ 'type' => 'string',
+ 'format' => 'binary',
+ ];
+ }
+
+ if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) {
+ $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases());
+
+ $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer';
+
+ if ($nullable) {
+ $enumCases[] = null;
+ }
+
+ return [
+ 'type' => $type,
+ 'enum' => $enumCases,
+ ];
+ }
+
+ if (true !== $readableLink && $this->isResourceClass($className)) {
+ return [
+ 'type' => 'string',
+ 'format' => 'iri-reference',
+ 'example' => 'https://example.com/',
+ ];
+ }
+
+ // TODO: add propertyNameCollectionFactory and recurse to find the underlying schema? Right now SchemaFactory does the job so we don't compute anything here.
+ return ['type' => Schema::UNKNOWN_TYPE];
+ }
+
+ /**
+ * @param array $jsonSchema
+ *
+ * @return array
+ */
+ private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array
+ {
+ if (!$type->isNullable()) {
+ return $jsonSchema;
+ }
+
+ if (\array_key_exists('$ref', $jsonSchema)) {
+ return ['anyOf' => [$jsonSchema, 'type' => 'null']];
+ }
+
+ return [...$jsonSchema, ...[
+ 'type' => \is_array($jsonSchema['type'])
+ ? array_merge($jsonSchema['type'], ['null'])
+ : [$jsonSchema['type'], 'null'],
+ ]];
+ }
+}
diff --git a/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..51232c3
--- /dev/null
+++ b/ResourceMetadataTrait.php
@@ -0,0 +1,105 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\JsonSchema;
+
+use ApiPlatform\Metadata\CollectionOperationInterface;
+use ApiPlatform\Metadata\Exception\OperationNotFoundException;
+use ApiPlatform\Metadata\HttpOperation;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
+use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
+
+/**
+ * @internal
+ */
+trait ResourceMetadataTrait
+{
+ use ResourceClassInfoTrait;
+
+ private function findOutputClass(string $className, string $type, Operation $operation, ?array $serializerContext): ?string
+ {
+ $inputOrOutput = ['class' => $className];
+ $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
+ $forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false;
+
+ return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
+ }
+
+ private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext): Operation
+ {
+ if (null === $operation) {
+ if (null === $this->resourceMetadataFactory) {
+ return new HttpOperation();
+ }
+ $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
+
+ try {
+ $operation = $resourceMetadataCollection->getOperation();
+ } catch (OperationNotFoundException $e) {
+ $operation = new HttpOperation();
+ }
+ $forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false;
+ if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) {
+ $operation = new HttpOperation();
+ }
+
+ return $this->findOperationForType($resourceMetadataCollection, $type, $operation);
+ }
+
+ // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
+ if ($this->resourceMetadataFactory && !$operation->getClass()) {
+ $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
+
+ if ($operation->getName()) {
+ return $resourceMetadataCollection->getOperation($operation->getName());
+ }
+
+ return $this->findOperationForType($resourceMetadataCollection, $type, $operation);
+ }
+
+ return $operation;
+ }
+
+ private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
+ {
+ // Find the operation and use the first one that matches criterias
+ foreach ($resourceMetadataCollection as $resourceMetadata) {
+ foreach ($resourceMetadata->getOperations() ?? [] as $op) {
+ if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
+ $operation = $op;
+ break 2;
+ }
+
+ if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
+ $operation = $op;
+ break 2;
+ }
+ }
+ }
+
+ return $operation;
+ }
+
+ private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
+ {
+ return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
+ }
+
+ private function getShortClassName(string $fullyQualifiedName): string
+ {
+ $parts = explode('\\', $fullyQualifiedName);
+
+ return end($parts);
+ }
+}
diff --git a/Schema.php b/Schema.php
index 4531649..9d7aa40 100644
--- a/Schema.php
+++ b/Schema.php
@@ -30,6 +30,7 @@ final class Schema extends \ArrayObject
public const VERSION_JSON_SCHEMA = 'json-schema';
public const VERSION_OPENAPI = 'openapi';
public const VERSION_SWAGGER = 'swagger';
+ public const UNKNOWN_TYPE = 'unknown_type';
public function __construct(private readonly string $version = self::VERSION_JSON_SCHEMA)
{
diff --git a/SchemaFactory.php b/SchemaFactory.php
index 53de46d..610c242 100644
--- a/SchemaFactory.php
+++ b/SchemaFactory.php
@@ -13,18 +13,15 @@
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 Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
@@ -33,53 +30,62 @@
*
* @author Kévin Dunglas
*/
-final class SchemaFactory implements SchemaFactoryInterface
+final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
{
- use ResourceClassInfoTrait;
- private array $distinctFormats = [];
-
+ use ResourceMetadataTrait;
+ private ?TypeFactoryInterface $typeFactory = null;
+ private ?SchemaFactoryInterface $schemaFactory = null;
// Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object
public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link';
public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
- public function __construct(private readonly TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null)
+ public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null)
{
+ if ($typeFactory) {
+ trigger_deprecation('api-platform/core', '3.4', \sprintf('Injecting the "%s" inside "%s" is deprecated and "%s" will be removed in 4.x.', TypeFactoryInterface::class, self::class, TypeFactoryInterface::class));
+ $this->typeFactory = $typeFactory;
+ }
+ if (!$definitionNameFactory) {
+ $this->definitionNameFactory = new DefinitionNameFactory($this->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);
+ $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;
}
@@ -123,7 +129,7 @@ public function buildSchema(string $className, string $format = 'json', string $
$definition['externalDocs'] = ['url' => $operation->getTypes()[0]];
}
- $options = $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
+ $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
$propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options);
if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
@@ -135,16 +141,15 @@ public function buildSchema(string $className, string $format = 'json', string $
$definition['required'][] = $normalizedPropertyName;
}
- $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format);
+ $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type);
}
return $schema;
}
- private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format): void
+ private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void
{
$version = $schema->getVersion();
- $swagger = Schema::VERSION_SWAGGER === $version;
if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
$additionalPropertySchema = $propertyMetadata->getOpenapiContext();
} else {
@@ -156,189 +161,100 @@ 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() ?? [];
- if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault()) && (null === $type?->getClassName() || !$this->isResourceClass($type->getClassName()))) {
- if ($default instanceof \BackedEnum) {
- $default = $default->value;
- }
- $propertySchema['default'] = $default;
- }
+ // never override the following keys if at least one is already set
+ // or if property has no type(s) defined
+ // or if property schema is already fully defined (type=string + format || enum)
+ $propertySchemaType = $propertySchema['type'] ?? false;
- if (!isset($propertySchema['example']) && !empty($example = $propertyMetadata->getExample())) {
- $propertySchema['example'] = $example;
- }
+ $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType
+ || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null));
- if (!isset($propertySchema['example']) && isset($propertySchema['default'])) {
- $propertySchema['example'] = $propertySchema['default'];
+ if (
+ !$isUnknown && (
+ [] === $types
+ || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
+ || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType)
+ || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false)
+ )
+ ) {
+ $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
+
+ 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
+
+ // TODO: in 3.3 add trigger_deprecation() as type factories are not used anymore, we moved this logic to SchemaPropertyMetadataFactory so that it gets cached
+ if ($typeFromFactory = $this->typeFactory?->getType($type, 'jsonschema', $propertyMetadata->isReadableLink(), $serializerContext)) {
+ $propertySchema = $typeFromFactory;
+ break;
+ }
+
+ $isCollection = $type->isCollection();
+ if ($isCollection) {
$valueType = $type->getCollectionValueTypes()[0] ?? null;
} 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;
}
- $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema);
- }
-
- if (\array_key_exists('type', $propertySchema) && \array_key_exists('$ref', $valueSchema)) {
- $propertySchema = new \ArrayObject($propertySchema);
- } else {
- $propertySchema = new \ArrayObject($propertySchema + $valueSchema);
- }
- $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
- }
-
- private function buildDefinitionName(string $className, string $format = 'json', string $inputOrOutputClass = null, Operation $operation = null, array $serializerContext = null): string
- {
- if ($operation) {
- $prefix = $operation->getShortName();
- }
-
- if (!isset($prefix)) {
- $prefix = (new \ReflectionClass($className))->getShortName();
- }
-
- if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
- $shortName = $this->getShortClassName($inputOrOutputClass);
- $prefix .= '.'.$shortName;
- }
-
- if (isset($this->distinctFormats[$format])) {
- // JSON is the default, and so isn't included in the definition name
- $prefix .= '.'.$format;
- }
-
- $definitionName = $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null;
- if ($definitionName) {
- $name = sprintf('%s-%s', $prefix, $definitionName);
- } else {
- $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
- $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
- }
-
- return $this->encodeDefinitionName($name);
- }
-
- private function encodeDefinitionName(string $name): string
- {
- return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
- }
-
- private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, Operation $operation = null, array $serializerContext = null): ?array
- {
- if (!$this->isResourceClass($className)) {
- return [
- null,
- $serializerContext ?? [],
- [],
- $className,
- ];
- }
-
- $forceSubschema = $serializerContext[self::FORCE_SUBSCHEMA] ?? false;
- if (null === $operation) {
- $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
- try {
- $operation = $resourceMetadataCollection->getOperation();
- } catch (OperationNotFoundException $e) {
- $operation = new HttpOperation();
- }
- if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) {
- $operation = new HttpOperation();
+ $subSchemaFactory = $this->schemaFactory ?: $this;
+ $subSchema = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
+ if (!isset($subSchema['$ref'])) {
+ continue;
}
- $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);
- }
+ if ($isCollection) {
+ $propertySchema['items']['$ref'] = $subSchema['$ref'];
+ unset($propertySchema['items']['type']);
+ break;
}
- }
- $inputOrOutput = ['class' => $className];
- $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
- $outputClass = $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
-
- if (null === $outputClass) {
- // input or output disabled
- return null;
+ $refs[] = ['$ref' => $subSchema['$ref']];
+ $isNullable = $isNullable ?? $type->isNullable();
}
- return [
- $operation,
- $serializerContext ?? $this->getSerializerContext($operation, $type),
- $this->getValidationGroups($operation),
- $outputClass,
- ];
- }
-
- private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
- {
- // Find the operation and use the first one that matches criterias
- foreach ($resourceMetadataCollection as $resourceMetadata) {
- foreach ($resourceMetadata->getOperations() ?? [] as $op) {
- if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
- $operation = $op;
- break 2;
- }
-
- if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
- $operation = $op;
- break 2;
- }
- }
+ if ($isNullable) {
+ $refs[] = ['type' => 'null'];
}
- return $operation;
- }
+ if (($c = \count($refs)) > 1) {
+ $propertySchema['anyOf'] = $refs;
+ unset($propertySchema['type']);
+ } elseif (1 === $c) {
+ $propertySchema['$ref'] = $refs[0]['$ref'];
+ unset($propertySchema['type']);
+ }
- private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
- {
- return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
+ $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
}
private function getValidationGroups(Operation $operation): array
@@ -351,7 +267,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 */
@@ -378,10 +294,8 @@ private function getFactoryOptions(array $serializerContext, array $validationGr
return $options;
}
- private function getShortClassName(string $fullyQualifiedName): string
+ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
{
- $parts = explode('\\', $fullyQualifiedName);
-
- return end($parts);
+ $this->schemaFactory = $schemaFactory;
}
}
diff --git a/SchemaFactoryAwareInterface.php b/SchemaFactoryAwareInterface.php
new file mode 100644
index 0000000..fd9338e
--- /dev/null
+++ b/SchemaFactoryAwareInterface.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\JsonSchema;
+
+interface SchemaFactoryAwareInterface
+{
+ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void;
+}
diff --git a/SchemaFactoryInterface.php b/SchemaFactoryInterface.php
index 3248156..ec99290 100644
--- a/SchemaFactoryInterface.php
+++ b/SchemaFactoryInterface.php
@@ -25,5 +25,5 @@ interface SchemaFactoryInterface
/**
* Builds the JSON Schema document corresponding to the given PHP class.
*/
- public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, Operation $operation = null, Schema $schema = null, array $serializerContext = null, bool $forceCollection = false): Schema;
+ public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema;
}
diff --git a/Tests/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/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/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..22517c6
--- /dev/null
+++ b/Tests/Fixtures/DummyWithCustomOpenApiContext.php
@@ -0,0 +1,33 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\JsonSchema\Tests\Fixtures;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+#[ApiResource]
+class DummyWithCustomOpenApiContext
+{
+ #[ApiProperty(openapiContext: ['type' => 'object', 'properties' => ['alpha' => ['type' => 'integer']]])]
+ public $acme;
+}
diff --git a/Tests/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..f6a5519 100644
--- a/Tests/Fixtures/NotAResource.php
+++ b/Tests/Fixtures/NotAResource.php
@@ -30,7 +30,7 @@ public function __construct(
/**
* @Groups("contain_non_resource")
*/
- private $bar
+ private $bar,
) {
}
diff --git a/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php b/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php
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..ec08170
--- /dev/null
+++ b/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php
@@ -0,0 +1,52 @@
+
+ *
+ * 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\TestCase;
+use Symfony\Component\PropertyInfo\Type;
+
+class SchemaPropertyMetadataFactoryTest extends TestCase
+{
+ public function testEnum(): void
+ {
+ $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
+ $apiProperty = new ApiProperty(builtinTypes: [new Type(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)]);
+ $decorated = $this->createMock(PropertyMetadataFactoryInterface::class);
+ $decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'intEnumAsIdentifier')->willReturn($apiProperty);
+ $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated);
+ $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'intEnumAsIdentifier');
+ $this->assertEquals(['type' => ['integer', 'null'], 'enum' => [1, 2, null]], $apiProperty->getSchema());
+ }
+
+ public function testWithCustomOpenApiContext(): void
+ {
+ $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
+ $apiProperty = new ApiProperty(
+ builtinTypes: [new Type(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)],
+ openapiContext: ['type' => 'object', 'properties' => ['alpha' => ['type' => 'integer']]],
+ );
+ $decorated = $this->createMock(PropertyMetadataFactoryInterface::class);
+ $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'acme')->willReturn($apiProperty);
+ $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated);
+ $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'acme');
+ $this->assertEquals([], $apiProperty->getSchema());
+ }
+}
diff --git a/Tests/SchemaFactoryTest.php b/Tests/SchemaFactoryTest.php
index 806db5d..bf3bd56 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;
@@ -41,47 +44,53 @@ class SchemaFactoryTest extends TestCase
public function testBuildSchemaForNonResourceClass(): void
{
- $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class);
- $typeFactoryProphecy->getType(Argument::allOf(
- Argument::type(Type::class),
- Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING)
- ), Argument::cetera())->willReturn([
- 'type' => 'string',
- ]);
- $typeFactoryProphecy->getType(Argument::allOf(
- Argument::type(Type::class),
- Argument::which('getBuiltinType', Type::BUILTIN_TYPE_INT)
- ), Argument::cetera())->willReturn([
- 'type' => 'integer',
- ]);
- $typeFactoryProphecy->getType(Argument::allOf(
- Argument::type(Type::class),
- Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT)
- ), Argument::cetera())->willReturn([
- 'type' => 'object',
- ]);
-
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar', 'genderType']));
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
- $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true));
- $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withReadable(true)->withDefault('default_bar')->withExample('example_bar'));
- $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)])->withReadable(true)->withDefault(GenderTypeEnum::MALE));
+ $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])
+ ->withReadable(true)
+ ->withSchema(['type' => 'string'])
+ );
+ $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])
+ ->withReadable(true)
+ ->withDefault('default_bar')
+ ->withExample('example_bar')
+ ->withSchema(['type' => 'integer', 'default' => 'default_bar', 'example' => 'example_bar'])
+ );
+ $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)])
+ ->withReadable(true)
+ ->withDefault('male')
+ ->withSchema(['type' => 'object', 'default' => 'male', 'example' => 'male'])
+ );
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false);
- $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal());
+ $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]);
+
+ $schemaFactory = new SchemaFactory(
+ typeFactory: null,
+ resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(),
+ propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(),
+ propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(),
+ resourceClassResolver: $resourceClassResolverProphecy->reveal(),
+ definitionNameFactory: $definitionNameFactory,
+ );
$resultSchema = $schemaFactory->buildSchema(NotAResource::class);
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
$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 +118,79 @@ public function testBuildSchemaForNonResourceClass(): void
$this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']);
}
- public function testBuildSchemaWithSerializerGroups(): void
+ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): void
{
- $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class);
- $typeFactoryProphecy->getType(Argument::allOf(
- Argument::type(Type::class),
- Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING)
- ), Argument::cetera())->willReturn([
- 'type' => 'string',
- ]);
- $typeFactoryProphecy->getType(Argument::allOf(
- Argument::type(Type::class),
- Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT)
- ), Argument::cetera())->willReturn([
- 'type' => 'object',
- ]);
+ $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
+
+ $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
+ $propertyNameCollectionFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, Argument::cetera())->willReturn(new PropertyNameCollection(['ignoredProperty', 'unionType', 'intersectType']));
+
+ $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
+ $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'ignoredProperty', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true)])
+ ->withReadable(true)
+ ->withSchema(['type' => ['string', 'null']])
+ );
+ $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true), new Type(Type::BUILTIN_TYPE_INT, nullable: true), new Type(Type::BUILTIN_TYPE_FLOAT, nullable: true)])
+ ->withReadable(true)
+ ->withSchema(['oneOf' => [
+ ['type' => ['string', 'null']],
+ ['type' => ['integer', 'null']],
+ ]])
+ );
+ $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'intersectType', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, class: Serializable::class), new Type(Type::BUILTIN_TYPE_OBJECT, class: DummyResourceInterface::class)])
+ ->withReadable(true)
+ ->withSchema(['type' => 'object'])
+ );
+
+ $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
+ $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false);
+
+ $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]);
+
+ $schemaFactory = new SchemaFactory(
+ typeFactory: null,
+ resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(),
+ propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(),
+ propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(),
+ resourceClassResolver: $resourceClassResolverProphecy->reveal(),
+ definitionNameFactory: $definitionNameFactory,
+ );
+ $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class);
+
+ $rootDefinitionKey = $resultSchema->getRootDefinitionKey();
+ $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 testBuildSchemaWithSerializerGroups(): void
+ {
$shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName();
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$operation = (new Put())->withName('put')->withNormalizationContext([
@@ -144,22 +210,46 @@ public function testBuildSchemaWithSerializerGroups(): void
$propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description', 'genderType']));
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
- $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true));
- $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true));
- $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)])->withReadable(true)->withDefault(GenderTypeEnum::MALE));
+ $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])
+ ->withReadable(true)
+ ->withSchema(['type' => 'string'])
+ );
+ $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])
+ ->withReadable(true)
+ ->withSchema(['type' => 'string'])
+ );
+ $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)])
+ ->withReadable(true)
+ ->withDefault(GenderTypeEnum::MALE)
+ ->withSchema(['type' => 'object'])
+ );
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true);
$resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true);
- $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal());
+ $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]);
+
+ $schemaFactory = new SchemaFactory(
+ typeFactory: null,
+ resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(),
+ propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(),
+ propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(),
+ resourceClassResolver: $resourceClassResolverProphecy->reveal(),
+ definitionNameFactory: $definitionNameFactory,
+ );
$resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]);
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
$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']);
@@ -180,53 +270,44 @@ public function testBuildSchemaWithSerializerGroups(): void
public function testBuildSchemaForAssociativeArray(): void
{
- $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class);
- $typeFactoryProphecy->getType(Argument::allOf(
- Argument::type(Type::class),
- Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING),
- Argument::which('isCollection', true),
- Argument::that(function (Type $type): bool {
- $keyTypes = $type->getCollectionKeyTypes();
-
- return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_INT === $keyTypes[0]->getBuiltinType();
- })
- ), Argument::cetera())->willReturn([
- 'type' => 'array',
- ]);
- $typeFactoryProphecy->getType(Argument::allOf(
- Argument::type(Type::class),
- Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING),
- Argument::which('isCollection', true),
- Argument::that(function (Type $type): bool {
- $keyTypes = $type->getCollectionKeyTypes();
-
- return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_STRING === $keyTypes[0]->getBuiltinType();
- })
- ), Argument::cetera())->willReturn([
- 'type' => 'object',
- 'additionalProperties' => Type::BUILTIN_TYPE_STRING,
- ]);
-
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar']));
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
- $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true));
- $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true));
+ $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))])
+ ->withReadable(true)
+ ->withSchema(['type' => 'array', 'items' => ['string', 'int']])
+ );
+ $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn(
+ (new ApiProperty())
+ ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))])
+ ->withReadable(true)
+ ->withSchema(['type' => 'object', 'additionalProperties' => 'string'])
+ );
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false);
- $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal());
+ $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]);
+
+ $schemaFactory = new SchemaFactory(
+ typeFactory: null,
+ resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(),
+ propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(),
+ propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(),
+ resourceClassResolver: $resourceClassResolverProphecy->reveal(),
+ definitionNameFactory: $definitionNameFactory,
+ );
$resultSchema = $schemaFactory->buildSchema(NotAResource::class);
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
$definitions = $resultSchema->getDefinitions();
$this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey);
- // @noRector
$this->assertTrue(isset($definitions[$rootDefinitionKey]));
$this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]);
$this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']);
diff --git a/Tests/SchemaTest.php b/Tests/SchemaTest.php
index 554d3f2..73175d0 100644
--- a/Tests/SchemaTest.php
+++ b/Tests/SchemaTest.php
@@ -73,12 +73,10 @@ public function testDefinitions(string $version, array $baseDefinitions): void
if (Schema::VERSION_OPENAPI === $version) {
$this->assertArrayHasKey('schemas', $schema['components']);
} else {
- // @noRector
$this->assertTrue(isset($schema['definitions']));
}
$definitions = $schema->getDefinitions();
- // @noRector
$this->assertTrue(isset($definitions['foo']));
$this->assertArrayNotHasKey('definitions', $schema->getArrayCopy(false));
diff --git a/TypeFactory.php b/TypeFactory.php
index c3a94c5..3921913 100644
--- a/TypeFactory.php
+++ b/TypeFactory.php
@@ -23,6 +23,8 @@
/**
* {@inheritdoc}
*
+ * @deprecated since 3.3 https://github.com/api-platform/core/pull/5470
+ *
* @author Kévin Dunglas
*/
final class TypeFactory implements TypeFactoryInterface
@@ -31,7 +33,7 @@ final class TypeFactory implements TypeFactoryInterface
private ?SchemaFactoryInterface $schemaFactory = null;
- public function __construct(ResourceClassResolverInterface $resourceClassResolver = null)
+ public function __construct(?ResourceClassResolverInterface $resourceClassResolver = null)
{
$this->resourceClassResolver = $resourceClassResolver;
}
@@ -44,8 +46,13 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
/**
* {@inheritdoc}
*/
- public function getType(Type $type, string $format = 'json', bool $readableLink = null, array $serializerContext = null, Schema $schema = null): array
+ public function getType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, ?Schema $schema = null): array
{
+ if ('jsonschema' === $format) {
+ return [];
+ }
+
+ // TODO: OpenApiFactory uses this to compute filter types
if ($type->isCollection()) {
$keyType = $type->getCollectionKeyTypes()[0] ?? null;
$subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false);
@@ -66,7 +73,7 @@ public function getType(Type $type, string $format = 'json', bool $readableLink
return $this->addNullabilityToTypeDefinition($this->makeBasicType($type, $format, $readableLink, $serializerContext, $schema), $type, $schema);
}
- private function makeBasicType(Type $type, string $format = 'json', bool $readableLink = null, array $serializerContext = null, Schema $schema = null): array
+ private function makeBasicType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, ?Schema $schema = null): array
{
return match ($type->getBuiltinType()) {
Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
diff --git a/TypeFactoryInterface.php b/TypeFactoryInterface.php
index 795854d..70a551f 100644
--- a/TypeFactoryInterface.php
+++ b/TypeFactoryInterface.php
@@ -18,6 +18,8 @@
/**
* Factory for creating the JSON Schema document which specifies the data type corresponding to a PHP type.
*
+ * @deprecated
+ *
* @author Kévin Dunglas
*/
interface TypeFactoryInterface
@@ -25,5 +27,5 @@ interface TypeFactoryInterface
/**
* Gets the JSON Schema document which specifies the data type corresponding to the given PHP type, and recursively adds needed new schema to the current schema if provided.
*/
- public function getType(Type $type, string $format = 'json', bool $readableLink = null, array $serializerContext = null, Schema $schema = null): array;
+ public function getType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, ?Schema $schema = null): array;
}
diff --git a/composer.json b/composer.json
index 4687122..dab8560 100644
--- a/composer.json
+++ b/composer.json
@@ -25,16 +25,16 @@
],
"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"
+ "api-platform/metadata": "^3.4 || ^4.0",
+ "symfony/console": "^6.4 || ^7.0",
+ "symfony/property-info": "^6.4 || ^7.1",
+ "symfony/serializer": "^6.4 || ^7.1",
+ "symfony/uid": "^6.4 || ^7.0"
},
"require-dev": {
"phpspec/prophecy-phpunit": "^2.0",
- "symfony/phpunit-bridge": "^6.1"
+ "symfony/phpunit-bridge": "^6.4 || ^7.0",
+ "sebastian/comparator": "<5.0"
},
"autoload": {
"psr-4": {
@@ -56,16 +56,18 @@
},
"extra": {
"branch-alias": {
- "dev-main": "3.2.x-dev"
+ "dev-main": "4.0.x-dev",
+ "dev-3.4": "3.4.x-dev"
},
"symfony": {
- "require": "^6.1"
+ "require": "^6.4 || ^7.1"
+ },
+ "thanks": {
+ "name": "api-platform/api-platform",
+ "url": "https://github.com/api-platform/api-platform"
}
},
- "repositories": [
- {
- "type": "path",
- "url": "../Metadata"
- }
- ]
+ "scripts": {
+ "test": "./vendor/bin/phpunit"
+ }
}