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

api-platform / core / 9562658349

18 Jun 2024 09:35AM UTC coverage: 62.637% (+0.4%) from 62.272%
9562658349

push

github

soyuka
Merge 3.4

52 of 55 new or added lines in 6 files covered. (94.55%)

236 existing lines in 20 files now uncovered.

11016 of 17587 relevant lines covered (62.64%)

60.45 hits per line

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

84.73
/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\Exception\PropertyNotFoundException;
17
use ApiPlatform\JsonSchema\Schema;
18
use ApiPlatform\Metadata\ApiProperty;
19
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
20
use ApiPlatform\Metadata\ResourceClassResolverInterface;
21
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
22
use Ramsey\Uuid\UuidInterface;
23
use Symfony\Component\PropertyInfo\Type;
24
use Symfony\Component\Uid\Ulid;
25
use Symfony\Component\Uid\Uuid;
26

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

34
    public const JSON_SCHEMA_USER_DEFINED = 'user_defined_schema';
35

36
    public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null)
37
    {
38
        $this->resourceClassResolver = $resourceClassResolver;
448✔
39
    }
40

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

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

60
        $link = (($options['schema_type'] ?? null) === Schema::TYPE_INPUT) ? $propertyMetadata->isWritableLink() : $propertyMetadata->isReadableLink();
144✔
61
        $propertySchema = $propertyMetadata->getSchema() ?? [];
144✔
62

63
        if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) {
144✔
64
            $propertySchema['readOnly'] = true;
108✔
65
        }
66

67
        if (!\array_key_exists('writeOnly', $propertySchema) && false === $propertyMetadata->isReadable()) {
144✔
68
            $propertySchema['writeOnly'] = true;
36✔
69
        }
70

71
        if (!\array_key_exists('description', $propertySchema) && null !== ($description = $propertyMetadata->getDescription())) {
144✔
72
            $propertySchema['description'] = $description;
24✔
73
        }
74

75
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
76
        if (!\array_key_exists('deprecated', $propertySchema) && null !== $propertyMetadata->getDeprecationReason()) {
144✔
77
            $propertySchema['deprecated'] = true;
12✔
78
        }
79

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

86
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
144✔
87

88
        if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) {
144✔
89
            if ($default instanceof \BackedEnum) {
24✔
90
                $default = $default->value;
12✔
91
            }
92
            $propertySchema['default'] = $default;
24✔
93
        }
94

95
        if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) {
144✔
96
            $propertySchema['example'] = $example;
8✔
97
        }
98

99
        if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) {
144✔
100
            $propertySchema['example'] = $propertySchema['default'];
24✔
101
        }
102

103
        // never override the following keys if at least one is already set
104
        if ([] === $types
144✔
105
            || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
144✔
106
        ) {
107
            return $propertyMetadata->withSchema($propertySchema);
16✔
108
        }
109

110
        $valueSchema = [];
144✔
111
        foreach ($types as $type) {
144✔
112
            if ($isCollection = $type->isCollection()) {
144✔
113
                $keyType = $type->getCollectionKeyTypes()[0] ?? null;
72✔
114
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
72✔
115
            } else {
116
                $keyType = null;
140✔
117
                $valueType = $type;
140✔
118
            }
119

120
            if (null === $valueType) {
144✔
121
                $builtinType = 'string';
24✔
122
                $className = null;
24✔
123
            } else {
124
                $builtinType = $valueType->getBuiltinType();
144✔
125
                $className = $valueType->getClassName();
144✔
126
            }
127

128
            if ($isCollection && null !== $propertyMetadata->getUriTemplate()) {
144✔
129
                $keyType = null;
8✔
130
                $isCollection = false;
8✔
131
            }
132

133
            $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link);
144✔
134
            if (!\in_array($propertyType, $valueSchema, true)) {
144✔
135
                $valueSchema[] = $propertyType;
144✔
136
            }
137
        }
138

139
        // only one builtInType detected (should be "type" or "$ref")
140
        if (1 === \count($valueSchema)) {
144✔
141
            return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]);
144✔
142
        }
143

144
        // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types
145
        try {
146
            $reflectionClass = new \ReflectionClass($resourceClass);
8✔
147
            $reflectionProperty = $reflectionClass->getProperty($property);
8✔
148
            $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf';
8✔
UNCOV
149
        } catch (\ReflectionException) {
×
150
            // cannot detect types
UNCOV
151
            $composition = 'anyOf';
×
152
        }
153

154
        return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]);
8✔
155
    }
156

157
    private function getType(Type $type, ?bool $readableLink = null): array
158
    {
159
        if (!$type->isCollection()) {
144✔
160
            return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type);
144✔
161
        }
162

163
        $keyType = $type->getCollectionKeyTypes()[0] ?? null;
72✔
164
        $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false);
72✔
165

166
        if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) {
72✔
167
            return $this->addNullabilityToTypeDefinition([
8✔
168
                'type' => 'object',
8✔
169
                'additionalProperties' => $this->getType($subType, $readableLink),
8✔
170
            ], $type);
8✔
171
        }
172

173
        return $this->addNullabilityToTypeDefinition([
72✔
174
            'type' => 'array',
72✔
175
            'items' => $this->getType($subType, $readableLink),
72✔
176
        ], $type);
72✔
177
    }
178

179
    private function typeToArray(Type $type, ?bool $readableLink = null): array
180
    {
181
        return match ($type->getBuiltinType()) {
144✔
182
            Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
144✔
183
            Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'],
144✔
184
            Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'],
144✔
185
            Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink),
144✔
186
            default => ['type' => 'string'],
144✔
187
        };
144✔
188
    }
189

190
    /**
191
     * 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.
192
     *
193
     * Note: if the class is not part of exceptions listed above, any class is considered as a resource.
194
     */
195
    private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array
196
    {
197
        if (null === $className) {
72✔
198
            return ['type' => 'string'];
×
199
        }
200

201
        if (is_a($className, \DateTimeInterface::class, true)) {
72✔
202
            return [
20✔
203
                'type' => 'string',
20✔
204
                'format' => 'date-time',
20✔
205
            ];
20✔
206
        }
207

208
        if (is_a($className, \DateInterval::class, true)) {
72✔
209
            return [
×
210
                'type' => 'string',
×
211
                'format' => 'duration',
×
212
            ];
×
213
        }
214

215
        if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) {
72✔
216
            return [
8✔
217
                'type' => 'string',
8✔
218
                'format' => 'uuid',
8✔
219
            ];
8✔
220
        }
221

222
        if (is_a($className, Ulid::class, true)) {
72✔
223
            return [
×
224
                'type' => 'string',
×
225
                'format' => 'ulid',
×
226
            ];
×
227
        }
228

229
        if (is_a($className, \SplFileInfo::class, true)) {
72✔
230
            return [
×
231
                'type' => 'string',
×
232
                'format' => 'binary',
×
233
            ];
×
234
        }
235

236
        if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) {
72✔
237
            $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases());
16✔
238

239
            $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer';
16✔
240

241
            if ($nullable) {
16✔
242
                $enumCases[] = null;
16✔
243
            }
244

245
            return [
16✔
246
                'type' => $type,
16✔
247
                'enum' => $enumCases,
16✔
248
            ];
16✔
249
        }
250

251
        if (true !== $readableLink && $this->isResourceClass($className)) {
72✔
252
            return [
36✔
253
                'type' => 'string',
36✔
254
                'format' => 'iri-reference',
36✔
255
                'example' => 'https://example.com/',
36✔
256
            ];
36✔
257
        }
258

259
        // TODO: add propertyNameCollectionFactory and recurse to find the underlying schema? Right now SchemaFactory does the job so we don't compute anything here.
260
        return ['type' => Schema::UNKNOWN_TYPE];
56✔
261
    }
262

263
    /**
264
     * @param array<string, mixed> $jsonSchema
265
     *
266
     * @return array<string, mixed>
267
     */
268
    private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array
269
    {
270
        if (!$type->isNullable()) {
144✔
271
            return $jsonSchema;
128✔
272
        }
273

274
        if (\array_key_exists('$ref', $jsonSchema)) {
68✔
275
            return ['anyOf' => [$jsonSchema, 'type' => 'null']];
×
276
        }
277

278
        return [...$jsonSchema, ...[
68✔
279
            'type' => \is_array($jsonSchema['type'])
68✔
280
                ? array_merge($jsonSchema['type'], ['null'])
×
281
                : [$jsonSchema['type'], 'null'],
68✔
282
        ]];
68✔
283
    }
284
}
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