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

api-platform / core / 13814792797

12 Mar 2025 03:09PM UTC coverage: 5.889% (-1.4%) from 7.289%
13814792797

Pull #7012

github

web-flow
Merge 199d44919 into 284937039
Pull Request #7012: doc: comment typo in ApiResource.php

10048 of 170615 relevant lines covered (5.89%)

5.17 hits per line

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

80.74
/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
    ) {
41
        $this->resourceClassResolver = $resourceClassResolver;
468✔
42
    }
43

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

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

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

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

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

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

78
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
79
        if (!\array_key_exists('deprecated', $propertySchema) && null !== $propertyMetadata->getDeprecationReason()) {
171✔
80
            $propertySchema['deprecated'] = true;
12✔
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
85
        if (!\array_key_exists('externalDocs', $propertySchema) && null !== ($iri = $propertyMetadata->getTypes()[0] ?? null)) {
171✔
86
            $propertySchema['externalDocs'] = ['url' => $iri];
12✔
87
        }
88

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

188
    private function typeToArray(Type $type, ?bool $readableLink = null): array
189
    {
190
        return match ($type->getBuiltinType()) {
171✔
191
            Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
132✔
192
            Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'],
21✔
193
            Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'],
21✔
194
            Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink),
90✔
195
            default => ['type' => 'string'],
171✔
196
        };
171✔
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
    {
208
        if (null === $className) {
90✔
209
            return ['type' => 'string'];
×
210
        }
211

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

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

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

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

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

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

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

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

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

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

271
        return ['type' => Schema::UNKNOWN_TYPE];
69✔
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
    {
281
        if (!$type->isNullable()) {
171✔
282
            return $jsonSchema;
165✔
283
        }
284

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

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