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

api-platform / core / 17723449516

15 Sep 2025 05:52AM UTC coverage: 0.0% (-22.6%) from 22.578%
17723449516

Pull #7383

github

web-flow
Merge fa5b61e35 into 949c3c975
Pull Request #7383: fix(metadata): compute isWritable during updates

0 of 6 new or added lines in 4 files covered. (0.0%)

11356 existing lines in 371 files now uncovered.

0 of 48868 relevant lines covered (0.0%)

0.0 hits per line

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

0.0
/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;
×
42
    }
43

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

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

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

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

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

UNCOV
74
        if (!\array_key_exists('description', $propertySchema) && null !== ($description = $propertyMetadata->getDescription())) {
×
UNCOV
75
            $propertySchema['description'] = $description;
×
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()) {
×
UNCOV
80
            $propertySchema['deprecated'] = true;
×
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)) {
×
UNCOV
86
            $propertySchema['externalDocs'] = ['url' => $iri];
×
87
        }
88

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

91
        if (
UNCOV
92
            !\array_key_exists('default', $propertySchema)
×
UNCOV
93
            && null !== ($default = $propertyMetadata->getDefault())
×
UNCOV
94
            && false === (\is_array($default) && empty($default))
×
UNCOV
95
            && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))
×
96
        ) {
UNCOV
97
            if ($default instanceof \BackedEnum) {
×
UNCOV
98
                $default = $default->value;
×
99
            }
UNCOV
100
            $propertySchema['default'] = $default;
×
101
        }
102

UNCOV
103
        if (!\array_key_exists('example', $propertySchema) && null !== ($example = $propertyMetadata->getExample()) && false === (\is_array($example) && empty($example))) {
×
UNCOV
104
            $propertySchema['example'] = $example;
×
105
        }
106

UNCOV
107
        if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) {
×
UNCOV
108
            $propertySchema['example'] = $propertySchema['default'];
×
109
        }
110

111
        // never override the following keys if at least one is already set or if there's a custom openapi context
112
        if (
UNCOV
113
            [] === $types
×
UNCOV
114
            || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
×
UNCOV
115
            || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? [])
×
116
        ) {
UNCOV
117
            return $propertyMetadata->withSchema($propertySchema);
×
118
        }
119

UNCOV
120
        $valueSchema = [];
×
UNCOV
121
        foreach ($types as $type) {
×
122
            // Temp fix for https://github.com/symfony/symfony/pull/52699
UNCOV
123
            if (ArrayCollection::class === $type->getClassName()) {
×
UNCOV
124
                $type = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes());
×
125
            }
126

UNCOV
127
            if ($isCollection = $type->isCollection()) {
×
UNCOV
128
                $keyType = $type->getCollectionKeyTypes()[0] ?? null;
×
UNCOV
129
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
×
130
            } else {
UNCOV
131
                $keyType = null;
×
UNCOV
132
                $valueType = $type;
×
133
            }
134

UNCOV
135
            if (null === $valueType) {
×
UNCOV
136
                $builtinType = 'string';
×
UNCOV
137
                $className = null;
×
138
            } else {
UNCOV
139
                $builtinType = $valueType->getBuiltinType();
×
UNCOV
140
                $className = $valueType->getClassName();
×
141
            }
142

UNCOV
143
            if ($isCollection && null !== $propertyMetadata->getUriTemplate()) {
×
144
                $keyType = null;
×
145
                $isCollection = false;
×
146
            }
147

UNCOV
148
            $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link);
×
UNCOV
149
            if (!\in_array($propertyType, $valueSchema, true)) {
×
UNCOV
150
                $valueSchema[] = $propertyType;
×
151
            }
152
        }
153

154
        // only one builtInType detected (should be "type" or "$ref")
UNCOV
155
        if (1 === \count($valueSchema)) {
×
UNCOV
156
            return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]);
×
157
        }
158

159
        // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types
160
        try {
UNCOV
161
            $reflectionClass = new \ReflectionClass($resourceClass);
×
UNCOV
162
            $reflectionProperty = $reflectionClass->getProperty($property);
×
UNCOV
163
            $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf';
×
164
        } catch (\ReflectionException) {
×
165
            // cannot detect types
166
            $composition = 'anyOf';
×
167
        }
168

UNCOV
169
        return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]);
×
170
    }
171

172
    private function getType(Type $type, ?bool $readableLink = null): array
173
    {
UNCOV
174
        if (!$type->isCollection()) {
×
UNCOV
175
            return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type);
×
176
        }
177

UNCOV
178
        $keyType = $type->getCollectionKeyTypes()[0] ?? null;
×
UNCOV
179
        $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false);
×
180

UNCOV
181
        if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) {
×
UNCOV
182
            return $this->addNullabilityToTypeDefinition([
×
UNCOV
183
                'type' => 'object',
×
UNCOV
184
                'additionalProperties' => $this->getType($subType, $readableLink),
×
UNCOV
185
            ], $type);
×
186
        }
187

UNCOV
188
        return $this->addNullabilityToTypeDefinition([
×
UNCOV
189
            'type' => 'array',
×
UNCOV
190
            'items' => $this->getType($subType, $readableLink),
×
UNCOV
191
        ], $type);
×
192
    }
193

194
    private function typeToArray(Type $type, ?bool $readableLink = null): array
195
    {
UNCOV
196
        return match ($type->getBuiltinType()) {
×
UNCOV
197
            Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
×
UNCOV
198
            Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'],
×
UNCOV
199
            Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'],
×
UNCOV
200
            Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink),
×
UNCOV
201
            default => ['type' => 'string'],
×
UNCOV
202
        };
×
203
    }
204

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

UNCOV
218
        if (is_a($className, \DateTimeInterface::class, true)) {
×
UNCOV
219
            return [
×
UNCOV
220
                'type' => 'string',
×
UNCOV
221
                'format' => 'date-time',
×
UNCOV
222
            ];
×
223
        }
224

UNCOV
225
        if (is_a($className, \DateInterval::class, true)) {
×
226
            return [
×
227
                'type' => 'string',
×
228
                'format' => 'duration',
×
229
            ];
×
230
        }
231

UNCOV
232
        if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) {
×
UNCOV
233
            return [
×
UNCOV
234
                'type' => 'string',
×
UNCOV
235
                'format' => 'uuid',
×
UNCOV
236
            ];
×
237
        }
238

UNCOV
239
        if (is_a($className, Ulid::class, true)) {
×
240
            return [
×
241
                'type' => 'string',
×
242
                'format' => 'ulid',
×
243
            ];
×
244
        }
245

UNCOV
246
        if (is_a($className, \SplFileInfo::class, true)) {
×
247
            return [
×
248
                'type' => 'string',
×
249
                'format' => 'binary',
×
250
            ];
×
251
        }
252

UNCOV
253
        $isResourceClass = $this->isResourceClass($className);
×
UNCOV
254
        if (!$isResourceClass && is_a($className, \BackedEnum::class, true)) {
×
UNCOV
255
            $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases());
×
256

UNCOV
257
            $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer';
×
258

UNCOV
259
            if ($nullable) {
×
UNCOV
260
                $enumCases[] = null;
×
261
            }
262

UNCOV
263
            return [
×
UNCOV
264
                'type' => $type,
×
UNCOV
265
                'enum' => $enumCases,
×
UNCOV
266
            ];
×
267
        }
268

UNCOV
269
        if (true !== $readableLink && $isResourceClass) {
×
UNCOV
270
            return [
×
UNCOV
271
                'type' => 'string',
×
UNCOV
272
                'format' => 'iri-reference',
×
UNCOV
273
                'example' => 'https://example.com/',
×
UNCOV
274
            ];
×
275
        }
276

UNCOV
277
        return ['type' => Schema::UNKNOWN_TYPE];
×
278
    }
279

280
    /**
281
     * @param array<string, mixed> $jsonSchema
282
     *
283
     * @return array<string, mixed>
284
     */
285
    private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array
286
    {
UNCOV
287
        if (!$type->isNullable()) {
×
UNCOV
288
            return $jsonSchema;
×
289
        }
290

UNCOV
291
        if (\array_key_exists('$ref', $jsonSchema)) {
×
292
            return ['anyOf' => [$jsonSchema, 'type' => 'null']];
×
293
        }
294

UNCOV
295
        return [...$jsonSchema, ...[
×
UNCOV
296
            'type' => \is_array($jsonSchema['type'])
×
297
                ? array_merge($jsonSchema['type'], ['null'])
×
UNCOV
298
                : [$jsonSchema['type'], 'null'],
×
UNCOV
299
        ]];
×
300
    }
301
}
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