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

Open in your IDE?
  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, $propertyMetadataFactory, NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $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 $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $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($operationName, OperationType::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', null, true) : $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 $schema, string $definitionName, string $normalizedPropertyName, $propertyMetadata, array $serializerContext, string $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 $className, string $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', $prefix, implode('_', $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 $className, string $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($resourceMetadata, string $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. }