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

api-platform / core / 15133993414

20 May 2025 09:30AM UTC coverage: 26.313% (-1.2%) from 27.493%
15133993414

Pull #7161

github

web-flow
Merge e2c03d45f into 5459ba375
Pull Request #7161: fix(metadata): infer parameter string type from schema

0 of 2 new or added lines in 1 file covered. (0.0%)

11019 existing lines in 363 files now uncovered.

12898 of 49018 relevant lines covered (26.31%)

34.33 hits per line

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

85.19
/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.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\Metadata\Property\Factory;
15

16
use ApiPlatform\JsonSchema\Schema;
17
use ApiPlatform\Metadata\ApiProperty;
18
use ApiPlatform\Metadata\Exception\PropertyNotFoundException;
19
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
20
use ApiPlatform\Metadata\ResourceClassResolverInterface;
21
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
22
use Doctrine\Common\Collections\ArrayCollection;
23
use Ramsey\Uuid\UuidInterface;
24
use Symfony\Component\PropertyInfo\Type;
25
use Symfony\Component\Uid\Ulid;
26
use Symfony\Component\Uid\Uuid;
27

28
/**
29
 * Build ApiProperty::schema.
30
 */
31
final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInterface
32
{
33
    use ResourceClassInfoTrait;
34

35
    public const JSON_SCHEMA_USER_DEFINED = 'user_defined_schema';
36

37
    public function __construct(
38
        ResourceClassResolverInterface $resourceClassResolver,
39
        private readonly ?PropertyMetadataFactoryInterface $decorated = null,
40
    ) {
UNCOV
41
        $this->resourceClassResolver = $resourceClassResolver;
979✔
42
    }
43

44
    public function create(string $resourceClass, string $property, array $options = []): ApiProperty
45
    {
UNCOV
46
        if (null === $this->decorated) {
240✔
47
            $propertyMetadata = new ApiProperty();
×
48
        } else {
49
            try {
UNCOV
50
                $propertyMetadata = $this->decorated->create($resourceClass, $property, $options);
240✔
51
            } catch (PropertyNotFoundException) {
×
52
                $propertyMetadata = new ApiProperty();
×
53
            }
54
        }
55

UNCOV
56
        $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
240✔
57
        // see AttributePropertyMetadataFactory
UNCOV
58
        if (true === ($extraProperties[self::JSON_SCHEMA_USER_DEFINED] ?? false)) {
240✔
59
            // schema seems to have been declared by the user: do not override nor complete user value
UNCOV
60
            return $propertyMetadata;
15✔
61
        }
62

UNCOV
63
        $link = (($options['schema_type'] ?? null) === Schema::TYPE_INPUT) ? $propertyMetadata->isWritableLink() : $propertyMetadata->isReadableLink();
240✔
UNCOV
64
        $propertySchema = $propertyMetadata->getSchema() ?? [];
240✔
65

UNCOV
66
        if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) {
240✔
UNCOV
67
            $propertySchema['readOnly'] = true;
174✔
68
        }
69

UNCOV
70
        if (!\array_key_exists('writeOnly', $propertySchema) && false === $propertyMetadata->isReadable()) {
240✔
UNCOV
71
            $propertySchema['writeOnly'] = true;
58✔
72
        }
73

UNCOV
74
        if (!\array_key_exists('description', $propertySchema) && null !== ($description = $propertyMetadata->getDescription())) {
240✔
UNCOV
75
            $propertySchema['description'] = $description;
87✔
76
        }
77

78
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
UNCOV
79
        if (!\array_key_exists('deprecated', $propertySchema) && null !== $propertyMetadata->getDeprecationReason()) {
240✔
UNCOV
80
            $propertySchema['deprecated'] = true;
9✔
81
        }
82

83
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
84
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
UNCOV
85
        if (!\array_key_exists('externalDocs', $propertySchema) && null !== ($iri = $propertyMetadata->getTypes()[0] ?? null)) {
240✔
UNCOV
86
            $propertySchema['externalDocs'] = ['url' => $iri];
28✔
87
        }
88

UNCOV
89
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
240✔
90

UNCOV
91
        if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) {
240✔
UNCOV
92
            if ($default instanceof \BackedEnum) {
54✔
UNCOV
93
                $default = $default->value;
7✔
94
            }
UNCOV
95
            $propertySchema['default'] = $default;
54✔
96
        }
97

UNCOV
98
        if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) {
240✔
UNCOV
99
            $propertySchema['example'] = $example;
4✔
100
        }
101

UNCOV
102
        if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) {
240✔
UNCOV
103
            $propertySchema['example'] = $propertySchema['default'];
54✔
104
        }
105

106
        // never override the following keys if at least one is already set or if there's a custom openapi context
UNCOV
107
        if ([] === $types
240✔
UNCOV
108
            || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
240✔
UNCOV
109
            || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? [])
240✔
110
        ) {
UNCOV
111
            return $propertyMetadata->withSchema($propertySchema);
35✔
112
        }
113

UNCOV
114
        $valueSchema = [];
225✔
UNCOV
115
        foreach ($types as $type) {
225✔
116
            // Temp fix for https://github.com/symfony/symfony/pull/52699
UNCOV
117
            if (ArrayCollection::class === $type->getClassName()) {
225✔
UNCOV
118
                $type = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes());
4✔
119
            }
120

UNCOV
121
            if ($isCollection = $type->isCollection()) {
225✔
UNCOV
122
                $keyType = $type->getCollectionKeyTypes()[0] ?? null;
77✔
UNCOV
123
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
77✔
124
            } else {
UNCOV
125
                $keyType = null;
225✔
UNCOV
126
                $valueType = $type;
225✔
127
            }
128

UNCOV
129
            if (null === $valueType) {
225✔
UNCOV
130
                $builtinType = 'string';
27✔
UNCOV
131
                $className = null;
27✔
132
            } else {
UNCOV
133
                $builtinType = $valueType->getBuiltinType();
225✔
UNCOV
134
                $className = $valueType->getClassName();
225✔
135
            }
136

UNCOV
137
            if ($isCollection && null !== $propertyMetadata->getUriTemplate()) {
225✔
138
                $keyType = null;
5✔
139
                $isCollection = false;
5✔
140
            }
141

UNCOV
142
            $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link);
225✔
UNCOV
143
            if (!\in_array($propertyType, $valueSchema, true)) {
225✔
UNCOV
144
                $valueSchema[] = $propertyType;
225✔
145
            }
146
        }
147

148
        // only one builtInType detected (should be "type" or "$ref")
UNCOV
149
        if (1 === \count($valueSchema)) {
225✔
UNCOV
150
            return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]);
225✔
151
        }
152

153
        // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types
154
        try {
UNCOV
155
            $reflectionClass = new \ReflectionClass($resourceClass);
7✔
UNCOV
156
            $reflectionProperty = $reflectionClass->getProperty($property);
7✔
UNCOV
157
            $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf';
7✔
158
        } catch (\ReflectionException) {
×
159
            // cannot detect types
160
            $composition = 'anyOf';
×
161
        }
162

UNCOV
163
        return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]);
7✔
164
    }
165

166
    private function getType(Type $type, ?bool $readableLink = null): array
167
    {
UNCOV
168
        if (!$type->isCollection()) {
225✔
UNCOV
169
            return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type);
225✔
170
        }
171

UNCOV
172
        $keyType = $type->getCollectionKeyTypes()[0] ?? null;
75✔
UNCOV
173
        $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false);
75✔
174

UNCOV
175
        if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) {
75✔
UNCOV
176
            return $this->addNullabilityToTypeDefinition([
4✔
UNCOV
177
                'type' => 'object',
4✔
UNCOV
178
                'additionalProperties' => $this->getType($subType, $readableLink),
4✔
UNCOV
179
            ], $type);
4✔
180
        }
181

UNCOV
182
        return $this->addNullabilityToTypeDefinition([
75✔
UNCOV
183
            'type' => 'array',
75✔
UNCOV
184
            'items' => $this->getType($subType, $readableLink),
75✔
UNCOV
185
        ], $type);
75✔
186
    }
187

188
    private function typeToArray(Type $type, ?bool $readableLink = null): array
189
    {
UNCOV
190
        return match ($type->getBuiltinType()) {
225✔
UNCOV
191
            Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
207✔
UNCOV
192
            Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'],
157✔
UNCOV
193
            Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'],
157✔
UNCOV
194
            Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink),
185✔
UNCOV
195
            default => ['type' => 'string'],
225✔
UNCOV
196
        };
225✔
197
    }
198

199
    /**
200
     * 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.
201
     *
202
     * Note: if the class is not part of exceptions listed above, any class is considered as a resource.
203
     *
204
     * @throws PropertyNotFoundException
205
     */
206
    private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array
207
    {
UNCOV
208
        if (null === $className) {
117✔
209
            return ['type' => 'string'];
×
210
        }
211

UNCOV
212
        if (is_a($className, \DateTimeInterface::class, true)) {
117✔
UNCOV
213
            return [
41✔
UNCOV
214
                'type' => 'string',
41✔
UNCOV
215
                'format' => 'date-time',
41✔
UNCOV
216
            ];
41✔
217
        }
218

UNCOV
219
        if (is_a($className, \DateInterval::class, true)) {
106✔
220
            return [
×
221
                'type' => 'string',
×
222
                'format' => 'duration',
×
223
            ];
×
224
        }
225

UNCOV
226
        if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) {
106✔
UNCOV
227
            return [
3✔
UNCOV
228
                'type' => 'string',
3✔
UNCOV
229
                'format' => 'uuid',
3✔
UNCOV
230
            ];
3✔
231
        }
232

UNCOV
233
        if (is_a($className, Ulid::class, true)) {
106✔
234
            return [
×
235
                'type' => 'string',
×
236
                'format' => 'ulid',
×
237
            ];
×
238
        }
239

UNCOV
240
        if (is_a($className, \SplFileInfo::class, true)) {
106✔
241
            return [
×
242
                'type' => 'string',
×
243
                'format' => 'binary',
×
244
            ];
×
245
        }
246

UNCOV
247
        $isResourceClass = $this->isResourceClass($className);
106✔
UNCOV
248
        if (!$isResourceClass && is_a($className, \BackedEnum::class, true)) {
106✔
UNCOV
249
            $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases());
9✔
250

UNCOV
251
            $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer';
9✔
252

UNCOV
253
            if ($nullable) {
9✔
UNCOV
254
                $enumCases[] = null;
9✔
255
            }
256

UNCOV
257
            return [
9✔
UNCOV
258
                'type' => $type,
9✔
UNCOV
259
                'enum' => $enumCases,
9✔
UNCOV
260
            ];
9✔
261
        }
262

UNCOV
263
        if (true !== $readableLink && $isResourceClass) {
105✔
UNCOV
264
            return [
62✔
UNCOV
265
                'type' => 'string',
62✔
UNCOV
266
                'format' => 'iri-reference',
62✔
UNCOV
267
                'example' => 'https://example.com/',
62✔
UNCOV
268
            ];
62✔
269
        }
270

UNCOV
271
        return ['type' => Schema::UNKNOWN_TYPE];
67✔
272
    }
273

274
    /**
275
     * @param array<string, mixed> $jsonSchema
276
     *
277
     * @return array<string, mixed>
278
     */
279
    private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array
280
    {
UNCOV
281
        if (!$type->isNullable()) {
225✔
UNCOV
282
            return $jsonSchema;
212✔
283
        }
284

UNCOV
285
        if (\array_key_exists('$ref', $jsonSchema)) {
115✔
286
            return ['anyOf' => [$jsonSchema, 'type' => 'null']];
×
287
        }
288

UNCOV
289
        return [...$jsonSchema, ...[
115✔
UNCOV
290
            'type' => \is_array($jsonSchema['type'])
115✔
291
                ? array_merge($jsonSchema['type'], ['null'])
×
UNCOV
292
                : [$jsonSchema['type'], 'null'],
115✔
UNCOV
293
        ]];
115✔
294
    }
295
}
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

© 2025 Coveralls, Inc