vendor/api-platform/core/src/Core/JsonSchema/SchemaFactory.php line 70

  1. <?php
  2. /*
  3.  * This file is part of the API Platform project.
  4.  *
  5.  * (c) Kévin Dunglas <dunglas@gmail.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. declare(strict_types=1);
  11. namespace ApiPlatform\Core\JsonSchema;
  12. use ApiPlatform\Api\ResourceClassResolverInterface;
  13. use ApiPlatform\Core\Api\OperationType;
  14. use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface as LegacyPropertyMetadataFactoryInterface;
  15. use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface as LegacyPropertyNameCollectionFactoryInterface;
  16. use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
  17. use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
  18. use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
  19. use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer;
  20. use ApiPlatform\JsonSchema\TypeFactoryInterface;
  21. use ApiPlatform\Metadata\ApiProperty;
  22. use ApiPlatform\Metadata\HttpOperation;
  23. use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
  24. use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
  25. use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
  26. use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
  27. use ApiPlatform\OpenApi\Factory\OpenApiFactory;
  28. use ApiPlatform\Util\ResourceClassInfoTrait;
  29. use Symfony\Component\PropertyInfo\Type;
  30. use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
  31. use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
  32. /**
  33.  * {@inheritdoc}
  34.  *
  35.  * @experimental
  36.  *
  37.  * @author Kévin Dunglas <dunglas@gmail.com>
  38.  */
  39. final class SchemaFactory implements SchemaFactoryInterface
  40. {
  41.     use ResourceClassInfoTrait;
  42.     private $typeFactory;
  43.     /**
  44.      * @var LegacyPropertyNameCollectionFactoryInterface|PropertyNameCollectionFactoryInterface
  45.      */
  46.     private $propertyNameCollectionFactory;
  47.     /**
  48.      * @var LegacyPropertyMetadataFactoryInterface|PropertyMetadataFactoryInterface
  49.      */
  50.     private $propertyMetadataFactory;
  51.     private $nameConverter;
  52.     private $distinctFormats = [];
  53.     /**
  54.      * @param TypeFactoryInterface $typeFactory
  55.      * @param mixed                $resourceMetadataFactory
  56.      * @param mixed                $propertyNameCollectionFactory
  57.      * @param mixed                $propertyMetadataFactory
  58.      */
  59.     public function __construct($typeFactory$resourceMetadataFactory$propertyNameCollectionFactory$propertyMetadataFactoryNameConverterInterface $nameConverter nullResourceClassResolverInterface $resourceClassResolver null)
  60.     {
  61.         $this->typeFactory $typeFactory;
  62.         if (!$resourceMetadataFactory instanceof ResourceMetadataCollectionFactoryInterface) {
  63.             trigger_deprecation('api-platform/core''2.7'sprintf('Use "%s" instead of "%s".'ResourceMetadataCollectionFactoryInterface::class, ResourceMetadataFactoryInterface::class));
  64.         }
  65.         $this->resourceMetadataFactory $resourceMetadataFactory;
  66.         $this->propertyNameCollectionFactory $propertyNameCollectionFactory;
  67.         $this->propertyMetadataFactory $propertyMetadataFactory;
  68.         $this->nameConverter $nameConverter;
  69.         $this->resourceClassResolver $resourceClassResolver;
  70.     }
  71.     /**
  72.      * When added to the list, the given format will lead to the creation of a new definition.
  73.      *
  74.      * @internal
  75.      */
  76.     public function addDistinctFormat(string $format): void
  77.     {
  78.         $this->distinctFormats[$format] = true;
  79.     }
  80.     /**
  81.      * {@inheritdoc}
  82.      */
  83.     public function buildSchema(string $classNamestring $format 'json'string $type Schema::TYPE_OUTPUT, ?string $operationType null, ?string $operationName null, ?Schema $schema null, ?array $serializerContext nullbool $forceCollection false): Schema
  84.     {
  85.         $schema $schema ? clone $schema : new Schema();
  86.         if (null === $metadata $this->getMetadata($className$type$operationType$operationName$serializerContext)) {
  87.             return $schema;
  88.         }
  89.         [$resourceMetadata$serializerContext$validationGroups$inputOrOutputClass] = $metadata;
  90.         if (null === $resourceMetadata && (null !== $operationType || null !== $operationName)) {
  91.             throw new \LogicException('The $operationType and $operationName arguments must be null for non-resource class.');
  92.         }
  93.         $operation $resourceMetadata instanceof ResourceMetadataCollection $resourceMetadata->getOperation($operationNameOperationType::COLLECTION === $operationType) : null;
  94.         $version $schema->getVersion();
  95.         $definitionName $this->buildDefinitionName($className$format$inputOrOutputClass$resourceMetadata instanceof ResourceMetadata $resourceMetadata $operation$serializerContext);
  96.         $method $operation instanceof HttpOperation $operation->getMethod() : 'GET';
  97.         if (!$operation && (null === $operationType || null === $operationName)) {
  98.             $method Schema::TYPE_INPUT === $type 'POST' 'GET';
  99.         } elseif ($resourceMetadata instanceof ResourceMetadata) {
  100.             $method $resourceMetadata->getTypedOperationAttribute($operationType$operationName'method''GET');
  101.         }
  102.         if (Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST''PATCH''PUT'], true)) {
  103.             return $schema;
  104.         }
  105.         if (!isset($schema['$ref']) && !isset($schema['type'])) {
  106.             $ref Schema::VERSION_OPENAPI === $version '#/components/schemas/'.$definitionName '#/definitions/'.$definitionName;
  107.             if ($forceCollection || (OperationType::COLLECTION === $operationType && 'POST' !== $method)) {
  108.                 $schema['type'] = 'array';
  109.                 $schema['items'] = ['$ref' => $ref];
  110.             } else {
  111.                 $schema['$ref'] = $ref;
  112.             }
  113.         }
  114.         $definitions $schema->getDefinitions();
  115.         if (isset($definitions[$definitionName])) {
  116.             // Already computed
  117.             return $schema;
  118.         }
  119.         /** @var \ArrayObject<string, mixed> $definition */
  120.         $definition = new \ArrayObject(['type' => 'object']);
  121.         $definitions[$definitionName] = $definition;
  122.         if ($resourceMetadata instanceof ResourceMetadata) {
  123.             $definition['description'] = $resourceMetadata->getDescription() ?? '';
  124.         } else {
  125.             $definition['description'] = $operation ? ($operation->getDescription() ?? '') : '';
  126.         }
  127.         // additionalProperties are allowed by default, so it does not need to be set explicitly, unless allow_extra_attributes is false
  128.         // See https://json-schema.org/understanding-json-schema/reference/object.html#properties
  129.         if (false === ($serializerContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] ?? true)) {
  130.             $definition['additionalProperties'] = false;
  131.         }
  132.         // see https://github.com/json-schema-org/json-schema-spec/pull/737
  133.         if (
  134.             Schema::VERSION_SWAGGER !== $version
  135.         ) {
  136.             if (($resourceMetadata instanceof ResourceMetadata &&
  137.                     ($operationType && $operationName $resourceMetadata->getTypedOperationAttribute($operationType$operationName'deprecation_reason'nulltrue) : $resourceMetadata->getAttribute('deprecation_reason'null))
  138.             ) || ($operation && $operation->getDeprecationReason())
  139.             ) {
  140.                 $definition['deprecated'] = true;
  141.             }
  142.         }
  143.         // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
  144.         // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
  145.         if ($resourceMetadata instanceof ResourceMetadata && $resourceMetadata->getIri()) {
  146.             $definition['externalDocs'] = ['url' => $resourceMetadata->getIri()];
  147.         } elseif ($operation instanceof HttpOperation && ($operation->getTypes()[0] ?? null)) {
  148.             $definition['externalDocs'] = ['url' => $operation->getTypes()[0]];
  149.         }
  150.         // TODO: getFactoryOptions should be refactored because Item & Collection Operations don't exist anymore (API Platform 3.0)
  151.         $options $this->getFactoryOptions($serializerContext$validationGroups$operationType$operationName$operation instanceof HttpOperation $operation null);
  152.         foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass$options) as $propertyName) {
  153.             $propertyMetadata $this->propertyMetadataFactory->create($inputOrOutputClass$propertyName$options);
  154.             if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
  155.                 continue;
  156.             }
  157.             $normalizedPropertyName $this->nameConverter $this->nameConverter->normalize($propertyName$inputOrOutputClass$format$serializerContext) : $propertyName;
  158.             if ($propertyMetadata->isRequired()) {
  159.                 $definition['required'][] = $normalizedPropertyName;
  160.             }
  161.             $this->buildPropertySchema($schema$definitionName$normalizedPropertyName$propertyMetadata$serializerContext$format);
  162.         }
  163.         return $schema;
  164.     }
  165.     private function buildPropertySchema(Schema $schemastring $definitionNamestring $normalizedPropertyName$propertyMetadata, array $serializerContextstring $format): void
  166.     {
  167.         $version $schema->getVersion();
  168.         $swagger Schema::VERSION_SWAGGER === $version;
  169.         $propertySchema $propertyMetadata->getSchema() ?? [];
  170.         if ($propertyMetadata instanceof ApiProperty) {
  171.             $additionalPropertySchema $propertyMetadata->getOpenapiContext() ?? [];
  172.         } else {
  173.             switch ($version) {
  174.                 case Schema::VERSION_SWAGGER:
  175.                     $basePropertySchemaAttribute 'swagger_context';
  176.                     break;
  177.                 case Schema::VERSION_OPENAPI:
  178.                     $basePropertySchemaAttribute 'openapi_context';
  179.                     break;
  180.                 default:
  181.                     $basePropertySchemaAttribute 'json_schema_context';
  182.             }
  183.             $additionalPropertySchema $propertyMetadata->getAttributes()[$basePropertySchemaAttribute] ?? [];
  184.         }
  185.         $propertySchema array_merge(
  186.             $propertySchema,
  187.             $additionalPropertySchema
  188.         );
  189.         if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) {
  190.             $propertySchema['readOnly'] = true;
  191.         }
  192.         if (!$swagger && false === $propertyMetadata->isReadable()) {
  193.             $propertySchema['writeOnly'] = true;
  194.         }
  195.         if (null !== $description $propertyMetadata->getDescription()) {
  196.             $propertySchema['description'] = $description;
  197.         }
  198.         $deprecationReason $propertyMetadata instanceof PropertyMetadata $propertyMetadata->getAttribute('deprecation_reason') : $propertyMetadata->getDeprecationReason();
  199.         // see https://github.com/json-schema-org/json-schema-spec/pull/737
  200.         if (!$swagger && null !== $deprecationReason) {
  201.             $propertySchema['deprecated'] = true;
  202.         }
  203.         // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
  204.         // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
  205.         $iri $propertyMetadata instanceof PropertyMetadata $propertyMetadata->getIri() : $propertyMetadata->getTypes()[0] ?? null;
  206.         if (null !== $iri) {
  207.             $propertySchema['externalDocs'] = ['url' => $iri];
  208.         }
  209.         if (!isset($propertySchema['default']) && !empty($default $propertyMetadata->getDefault())) {
  210.             $propertySchema['default'] = $default;
  211.         }
  212.         if (!isset($propertySchema['example']) && !empty($example $propertyMetadata->getExample())) {
  213.             $propertySchema['example'] = $example;
  214.         }
  215.         if (!isset($propertySchema['example']) && isset($propertySchema['default'])) {
  216.             $propertySchema['example'] = $propertySchema['default'];
  217.         }
  218.         $valueSchema = [];
  219.         // TODO: 3.0 support multiple types, default value of types will be [] instead of null
  220.         $type $propertyMetadata instanceof PropertyMetadata $propertyMetadata->getType() : $propertyMetadata->getBuiltinTypes()[0] ?? null;
  221.         if (null !== $type) {
  222.             if ($isCollection $type->isCollection()) {
  223.                 $keyType method_exists(Type::class, 'getCollectionKeyTypes') ? ($type->getCollectionKeyTypes()[0] ?? null) : $type->getCollectionKeyType();
  224.                 $valueType method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType();
  225.             } else {
  226.                 $keyType null;
  227.                 $valueType $type;
  228.             }
  229.             if (null === $valueType) {
  230.                 $builtinType 'string';
  231.                 $className null;
  232.             } else {
  233.                 $builtinType $valueType->getBuiltinType();
  234.                 $className $valueType->getClassName();
  235.             }
  236.             $valueSchema $this->typeFactory->getType(new Type($builtinType$type->isNullable(), $className$isCollection$keyType$valueType), $format$propertyMetadata->isReadableLink(), $serializerContext$schema);
  237.         }
  238.         if (\array_key_exists('type'$propertySchema) && \array_key_exists('$ref'$valueSchema)) {
  239.             $propertySchema = new \ArrayObject($propertySchema);
  240.         } else {
  241.             $propertySchema = new \ArrayObject($propertySchema $valueSchema);
  242.         }
  243.         $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
  244.     }
  245.     private function buildDefinitionName(string $classNamestring $format 'json', ?string $inputOrOutputClass null$resourceMetadata null, ?array $serializerContext null): string
  246.     {
  247.         if ($resourceMetadata) {
  248.             $prefix $resourceMetadata instanceof ResourceMetadata $resourceMetadata->getShortName() : $resourceMetadata->getShortName();
  249.         }
  250.         if (!isset($prefix)) {
  251.             $prefix = (new \ReflectionClass($className))->getShortName();
  252.         }
  253.         if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
  254.             $parts explode('\\'$inputOrOutputClass);
  255.             $shortName end($parts);
  256.             $prefix .= '.'.$shortName;
  257.         }
  258.         if (isset($this->distinctFormats[$format])) {
  259.             // JSON is the default, and so isn't included in the definition name
  260.             $prefix .= '.'.$format;
  261.         }
  262.         $definitionName $serializerContext[OpenApiFactory::OPENAPI_DEFINITION_NAME] ?? $serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME] ?? null;
  263.         if ($definitionName) {
  264.             $name sprintf('%s-%s'$prefix$definitionName);
  265.         } else {
  266.             $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
  267.             $name $groups sprintf('%s-%s'$prefiximplode('_'$groups)) : $prefix;
  268.         }
  269.         return $this->encodeDefinitionName($name);
  270.     }
  271.     private function encodeDefinitionName(string $name): string
  272.     {
  273.         return preg_replace('/[^a-zA-Z0-9.\-_]/''.'$name);
  274.     }
  275.     private function getMetadata(string $classNamestring $type Schema::TYPE_OUTPUT, ?string $operationType null, ?string $operationName null, ?array $serializerContext null): ?array
  276.     {
  277.         if (!$this->isResourceClass($className)) {
  278.             return [
  279.                 null,
  280.                 $serializerContext ?? [],
  281.                 [],
  282.                 $className,
  283.             ];
  284.         }
  285.         /** @var ResourceMetadata|ResourceMetadataCollection $resourceMetadata */
  286.         $resourceMetadata $this->resourceMetadataFactory->create($className);
  287.         $attribute Schema::TYPE_OUTPUT === $type 'output' 'input';
  288.         $operation = ($this->resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) ? null $resourceMetadata->getOperation($operationName);
  289.         if ($this->resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
  290.             if (null === $operationType || null === $operationName) {
  291.                 $inputOrOutput $resourceMetadata->getAttribute($attribute, ['class' => $className]);
  292.             } else {
  293.                 $inputOrOutput $resourceMetadata->getTypedOperationAttribute($operationType$operationName$attribute, ['class' => $className], true);
  294.             }
  295.         } elseif ($operation) {
  296.             $inputOrOutput = (Schema::TYPE_OUTPUT === $type $operation->getOutput() : $operation->getInput()) ?? ['class' => $className];
  297.         } else {
  298.             $inputOrOutput = ['class' => $className];
  299.         }
  300.         if (null === ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null)) {
  301.             // input or output disabled
  302.             return null;
  303.         }
  304.         return [
  305.             $resourceMetadata,
  306.             $serializerContext ?? $this->getSerializerContext($resourceMetadata$type$operationType$operationName),
  307.             $this->getValidationGroups($this->resourceMetadataFactory instanceof ResourceMetadataFactoryInterface $resourceMetadata $operation$operationType$operationName),
  308.             $inputOrOutput['class'] ?? $inputOrOutput->class,
  309.         ];
  310.     }
  311.     private function getSerializerContext($resourceMetadatastring $type Schema::TYPE_OUTPUT, ?string $operationType null, ?string $operationName null): array
  312.     {
  313.         if ($resourceMetadata instanceof ResourceMetadata) {
  314.             $attribute Schema::TYPE_OUTPUT === $type 'normalization_context' 'denormalization_context';
  315.         } else {
  316.             $operation $resourceMetadata->getOperation($operationName);
  317.         }
  318.         if (null === $operationType || null === $operationName) {
  319.             if ($resourceMetadata instanceof ResourceMetadata) {
  320.                 return $resourceMetadata->getAttribute($attribute, []);
  321.             }
  322.             return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
  323.         }
  324.         if ($resourceMetadata instanceof ResourceMetadata) {
  325.             return $resourceMetadata->getTypedOperationAttribute($operationType$operationName$attribute, [], true);
  326.         }
  327.         return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
  328.     }
  329.     /**
  330.      * @param HttpOperation|ResourceMetadata|null $resourceMetadata
  331.      */
  332.     private function getValidationGroups($resourceMetadata, ?string $operationType, ?string $operationName): array
  333.     {
  334.         if ($resourceMetadata instanceof ResourceMetadata) {
  335.             $attribute 'validation_groups';
  336.             if (null === $operationType || null === $operationName) {
  337.                 return \is_array($validationGroups $resourceMetadata->getAttribute($attribute, [])) ? $validationGroups : [];
  338.             }
  339.             return \is_array($validationGroups $resourceMetadata->getTypedOperationAttribute($operationType$operationName$attribute, [], true)) ? $validationGroups : [];
  340.         }
  341.         $groups $resourceMetadata ? ($resourceMetadata->getValidationContext()['groups'] ?? []) : [];
  342.         return \is_array($groups) ? $groups : [$groups];
  343.     }
  344.     /**
  345.      * Gets the options for the property name collection / property metadata factories.
  346.      */
  347.     private function getFactoryOptions(array $serializerContext, array $validationGroups, ?string $operationType, ?string $operationName, ?HttpOperation $operation null): array
  348.     {
  349.         $options = [
  350.             /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
  351.             'enable_getter_setter_extraction' => true,
  352.         ];
  353.         if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
  354.             /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
  355.             $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
  356.         }
  357.         if ($this->resourceMetadataFactory instanceof ResourceMetadataCollectionFactoryInterface && $operation) {
  358.             $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
  359.             $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
  360.         }
  361.         if (null !== $operationType && null !== $operationName) {
  362.             switch ($operationType) {
  363.                 case OperationType::COLLECTION:
  364.                     $options['collection_operation_name'] = $operationName;
  365.                     break;
  366.                 case OperationType::ITEM:
  367.                     $options['item_operation_name'] = $operationName;
  368.                     break;
  369.                 default:
  370.                     break;
  371.             }
  372.         }
  373.         if ($validationGroups) {
  374.             $options['validation_groups'] = $validationGroups;
  375.         }
  376.         return $options;
  377.     }
  378. }