• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 9744944258

01 Jul 2024 01:15PM UTC coverage: 64.67% (+2.0%) from 62.637%
9744944258

push

github

soyuka
Merge 3.3

75 of 85 new or added lines in 15 files covered. (88.24%)

1 existing line in 1 file now uncovered.

11398 of 17625 relevant lines covered (64.67%)

66.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.95
/src/JsonSchema/SchemaFactory.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace ApiPlatform\JsonSchema;
15

16
use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory;
17
use ApiPlatform\Metadata\ApiProperty;
18
use ApiPlatform\Metadata\CollectionOperationInterface;
19
use ApiPlatform\Metadata\HttpOperation;
20
use ApiPlatform\Metadata\Operation;
21
use ApiPlatform\Metadata\Patch;
22
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Metadata\ResourceClassResolverInterface;
26
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
27
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
28

29
/**
30
 * {@inheritdoc}
31
 *
32
 * @author Kévin Dunglas <dunglas@gmail.com>
33
 */
34
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
35
{
36
    use ResourceMetadataTrait;
37

38
    private const PATCH_SCHEMA_POSTFIX = '.patch';
39

40
    private ?TypeFactoryInterface $typeFactory = null;
41
    private ?SchemaFactoryInterface $schemaFactory = null;
42
    // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object
43
    public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link';
44
    public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
45

46
    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)
47
    {
48
        if ($typeFactory) {
520✔
49
            $this->typeFactory = $typeFactory;
468✔
50
        }
51
        if (!$definitionNameFactory) {
520✔
52
            $this->definitionNameFactory = new DefinitionNameFactory($this->distinctFormats);
×
53
        }
54

55
        $this->resourceMetadataFactory = $resourceMetadataFactory;
520✔
56
        $this->resourceClassResolver = $resourceClassResolver;
520✔
57
    }
58

59
    /**
60
     * {@inheritdoc}
61
     */
62
    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
63
    {
64
        $schema = $schema ? clone $schema : new Schema();
224✔
65

66
        if (!$this->isResourceClass($className)) {
224✔
67
            $operation = null;
64✔
68
            $inputOrOutputClass = $className;
64✔
69
            $serializerContext ??= [];
64✔
70
        } else {
71
            $operation = $this->findOperation($className, $type, $operation, $serializerContext);
224✔
72
            $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext);
224✔
73
            $serializerContext ??= $this->getSerializerContext($operation, $type);
224✔
74
        }
75

76
        if (null === $inputOrOutputClass) {
224✔
77
            // input or output disabled
78
            return $schema;
28✔
79
        }
80

81
        $validationGroups = $operation ? $this->getValidationGroups($operation) : [];
224✔
82
        $version = $schema->getVersion();
224✔
83
        $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext);
224✔
84

85
        $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET';
224✔
86
        if (!$operation) {
224✔
87
            $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET';
64✔
88
        }
89

90
        // In case of FORCE_SUBSCHEMA an object can be writable through another class even though it has no POST operation
91
        if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
224✔
92
            return $schema;
28✔
93
        }
94

95
        $isJsonMergePatch = 'json' === $format && $operation instanceof Patch && Schema::TYPE_INPUT === $type;
224✔
96

97
        if ($isJsonMergePatch) {
224✔
98
            $definitionName .= self::PATCH_SCHEMA_POSTFIX;
28✔
99
        }
100

101
        if (!isset($schema['$ref']) && !isset($schema['type'])) {
224✔
102
            $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName;
224✔
103
            if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) {
224✔
104
                $schema['type'] = 'array';
88✔
105
                $schema['items'] = ['$ref' => $ref];
88✔
106
            } else {
107
                $schema['$ref'] = $ref;
192✔
108
            }
109
        }
110

111
        $definitions = $schema->getDefinitions();
224✔
112
        if (isset($definitions[$definitionName])) {
224✔
113
            // Already computed
114
            return $schema;
28✔
115
        }
116

117
        /** @var \ArrayObject<string, mixed> $definition */
118
        $definition = new \ArrayObject(['type' => 'object']);
224✔
119
        $definitions[$definitionName] = $definition;
224✔
120
        $definition['description'] = $operation ? ($operation->getDescription() ?? '') : '';
224✔
121

122
        // additionalProperties are allowed by default, so it does not need to be set explicitly, unless allow_extra_attributes is false
123
        // See https://json-schema.org/understanding-json-schema/reference/object.html#properties
124
        if (false === ($serializerContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] ?? true)) {
224✔
125
            $definition['additionalProperties'] = false;
×
126
        }
127

128
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
129
        if (Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason()) {
224✔
130
            $definition['deprecated'] = true;
28✔
131
        } else {
132
            $definition['deprecated'] = false;
224✔
133
        }
134

135
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
136
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
137
        if ($operation instanceof HttpOperation && ($operation->getTypes()[0] ?? null)) {
224✔
138
            $definition['externalDocs'] = ['url' => $operation->getTypes()[0]];
36✔
139
        }
140

141
        $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
224✔
142
        foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
224✔
143
            $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options);
172✔
144
            if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
172✔
145
                continue;
36✔
146
            }
147

148
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName;
172✔
149
            if ($propertyMetadata->isRequired() && !$isJsonMergePatch) {
172✔
150
                $definition['required'][] = $normalizedPropertyName;
56✔
151
            }
152

153
            $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type);
172✔
154
        }
155

156
        return $schema;
224✔
157
    }
158

159
    private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void
160
    {
161
        $version = $schema->getVersion();
172✔
162
        if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
172✔
163
            $additionalPropertySchema = $propertyMetadata->getOpenapiContext();
28✔
164
        } else {
165
            $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext();
144✔
166
        }
167

168
        $propertySchema = array_merge(
172✔
169
            $propertyMetadata->getSchema() ?? [],
172✔
170
            $additionalPropertySchema ?? []
172✔
171
        );
172✔
172

173
        // @see https://github.com/api-platform/core/issues/6299
174
        if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) {
172✔
UNCOV
175
            unset($propertySchema['type']);
×
176
        }
177

178
        $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
172✔
179
        // see AttributePropertyMetadataFactory
180
        if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) {
172✔
181
            // schema seems to have been declared by the user: do not override nor complete user value
182
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
32✔
183

184
            return;
32✔
185
        }
186

187
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
172✔
188

189
        // never override the following keys if at least one is already set
190
        // or if property has no type(s) defined
191
        // or if property schema is already fully defined (type=string + format || enum)
192
        $propertySchemaType = $propertySchema['type'] ?? false;
172✔
193

194
        $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType
172✔
195
            || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null));
172✔
196

197
        if (
198
            !$isUnknown && (
172✔
199
                [] === $types
172✔
200
                || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
172✔
201
                || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType)
172✔
202
                || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false)
172✔
203
            )
204
        ) {
205
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
120✔
206

207
            return;
120✔
208
        }
209

210
        // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
211
        // complete property schema with resource reference ($ref) only if it's related to an object
212
        $version = $schema->getVersion();
144✔
213
        $refs = [];
144✔
214
        $isNullable = null;
144✔
215

216
        foreach ($types as $type) {
144✔
217
            $subSchema = new Schema($version);
144✔
218
            $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
144✔
219

220
            // 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
221
            if ($typeFromFactory = $this->typeFactory?->getType($type, 'jsonschema', $propertyMetadata->isReadableLink(), $serializerContext)) {
144✔
222
                $propertySchema = $typeFromFactory;
32✔
223
                break;
32✔
224
            }
225

226
            $isCollection = $type->isCollection();
140✔
227
            if ($isCollection) {
140✔
228
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
84✔
229
            } else {
230
                $valueType = $type;
140✔
231
            }
232

233
            $className = $valueType?->getClassName();
140✔
234
            if (null === $className) {
140✔
235
                continue;
140✔
236
            }
237

238
            $subSchemaFactory = $this->schemaFactory ?: $this;
76✔
239
            $subSchema = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
76✔
240
            if (!isset($subSchema['$ref'])) {
76✔
241
                continue;
28✔
242
            }
243

244
            if ($isCollection) {
76✔
245
                $propertySchema['items']['$ref'] = $subSchema['$ref'];
64✔
246
                unset($propertySchema['items']['type']);
64✔
247
                break;
64✔
248
            }
249

250
            $refs[] = ['$ref' => $subSchema['$ref']];
60✔
251
            $isNullable = $isNullable ?? $type->isNullable();
60✔
252
        }
253

254
        if ($isNullable) {
144✔
255
            $refs[] = ['type' => 'null'];
44✔
256
        }
257

258
        if (($c = \count($refs)) > 1) {
144✔
259
            $propertySchema['anyOf'] = $refs;
44✔
260
            unset($propertySchema['type']);
44✔
261
        } elseif (1 === $c) {
144✔
262
            $propertySchema['$ref'] = $refs[0]['$ref'];
48✔
263
            unset($propertySchema['type']);
48✔
264
        }
265

266
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
144✔
267
    }
268

269
    private function getValidationGroups(Operation $operation): array
270
    {
271
        $groups = $operation->getValidationContext()['groups'] ?? [];
224✔
272

273
        return \is_array($groups) ? $groups : [$groups];
224✔
274
    }
275

276
    /**
277
     * Gets the options for the property name collection / property metadata factories.
278
     */
279
    private function getFactoryOptions(array $serializerContext, array $validationGroups, ?HttpOperation $operation = null): array
280
    {
281
        $options = [
224✔
282
            /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
283
            'enable_getter_setter_extraction' => true,
224✔
284
        ];
224✔
285

286
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
224✔
287
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
288
            $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
56✔
289
        }
290

291
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
224✔
292
            $options['normalization_groups'] = $normalizationGroups;
68✔
293
        }
294

295
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
224✔
296
            $options['denormalization_groups'] = $denormalizationGroups;
44✔
297
        }
298

299
        if ($validationGroups) {
224✔
300
            $options['validation_groups'] = $validationGroups;
×
301
        }
302

303
        return $options;
224✔
304
    }
305

306
    public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
307
    {
308
        $this->schemaFactory = $schemaFactory;
520✔
309
    }
310
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc