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

api-platform / core / 14635100171

24 Apr 2025 06:39AM UTC coverage: 8.271% (+0.02%) from 8.252%
14635100171

Pull #6904

github

web-flow
Merge c9cefd82e into a3e5e53ea
Pull Request #6904: feat(graphql): added support for graphql subscriptions to work for actions

0 of 73 new or added lines in 3 files covered. (0.0%)

1999 existing lines in 144 files now uncovered.

13129 of 158728 relevant lines covered (8.27%)

13.6 hits per line

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

88.41
/src/Hydra/Serializer/DocumentationNormalizer.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\Hydra\Serializer;
15

16
use ApiPlatform\Documentation\Documentation;
17
use ApiPlatform\JsonLd\ContextBuilder;
18
use ApiPlatform\JsonLd\ContextBuilderInterface;
19
use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait;
20
use ApiPlatform\Metadata\ApiProperty;
21
use ApiPlatform\Metadata\ApiResource;
22
use ApiPlatform\Metadata\CollectionOperationInterface;
23
use ApiPlatform\Metadata\ErrorResource;
24
use ApiPlatform\Metadata\HttpOperation;
25
use ApiPlatform\Metadata\Operation;
26
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
27
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
28
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
29
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
30
use ApiPlatform\Metadata\ResourceClassResolverInterface;
31
use ApiPlatform\Metadata\UrlGeneratorInterface;
32
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
33
use Symfony\Component\PropertyInfo\Type as LegacyType;
34
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
35
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
36
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
37
use Symfony\Component\TypeInfo\Type;
38
use Symfony\Component\TypeInfo\Type\CollectionType;
39
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
40
use Symfony\Component\TypeInfo\Type\ObjectType;
41
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
42
use Symfony\Component\TypeInfo\TypeIdentifier;
43

44
use const ApiPlatform\JsonLd\HYDRA_CONTEXT;
45

46
/**
47
 * Creates a machine readable Hydra API documentation.
48
 *
49
 * @author Kévin Dunglas <dunglas@gmail.com>
50
 */
51
final class DocumentationNormalizer implements NormalizerInterface
52
{
53
    use HydraPrefixTrait;
54
    public const FORMAT = 'jsonld';
55

56
    public function __construct(
57
        private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
58
        private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
59
        private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory,
60
        private readonly ResourceClassResolverInterface $resourceClassResolver,
61
        private readonly UrlGeneratorInterface $urlGenerator,
62
        private readonly ?NameConverterInterface $nameConverter = null,
63
        private readonly ?array $defaultContext = [],
64
        private readonly ?bool $entrypointEnabled = true,
65
    ) {
66
    }
1,160✔
67

68
    /**
69
     * {@inheritdoc}
70
     */
71
    public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
72
    {
73
        $classes = [];
6✔
74
        $entrypointProperties = [];
6✔
75
        $hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext);
6✔
76

77
        foreach ($object->getResourceNameCollection() as $resourceClass) {
6✔
78
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);
6✔
79

80
            $resourceMetadata = $resourceMetadataCollection[0];
6✔
81
            if (true === $resourceMetadata->getHideHydraOperation()) {
6✔
82
                continue;
6✔
83
            }
84

85
            $shortName = $resourceMetadata->getShortName();
6✔
86
            $prefixedShortName = $resourceMetadata->getTypes()[0] ?? "#$shortName";
6✔
87

88
            $this->populateEntrypointProperties($resourceMetadata, $shortName, $prefixedShortName, $entrypointProperties, $hydraPrefix, $resourceMetadataCollection);
6✔
89
            $classes[] = $this->getClass($resourceClass, $resourceMetadata, $shortName, $prefixedShortName, $context, $hydraPrefix, $resourceMetadataCollection);
6✔
90
        }
91

92
        return $this->computeDoc($object, $this->getClasses($entrypointProperties, $classes, $hydraPrefix), $hydraPrefix);
6✔
93
    }
94

95
    /**
96
     * Populates entrypoint properties.
97
     */
98
    private function populateEntrypointProperties(ApiResource $resourceMetadata, string $shortName, string $prefixedShortName, array &$entrypointProperties, string $hydraPrefix, ?ResourceMetadataCollection $resourceMetadataCollection = null): void
99
    {
100
        $hydraCollectionOperations = $this->getHydraOperations(true, $resourceMetadataCollection, $hydraPrefix);
6✔
101
        if (empty($hydraCollectionOperations)) {
6✔
102
            return;
6✔
103
        }
104

UNCOV
105
        $entrypointProperty = [
4✔
UNCOV
106
            '@type' => $hydraPrefix.'SupportedProperty',
4✔
UNCOV
107
            $hydraPrefix.'property' => [
4✔
UNCOV
108
                '@id' => \sprintf('#Entrypoint/%s', lcfirst($shortName)),
4✔
UNCOV
109
                '@type' => $hydraPrefix.'Link',
4✔
UNCOV
110
                'domain' => '#Entrypoint',
4✔
UNCOV
111
                'owl:maxCardinality' => 1,
4✔
UNCOV
112
                'range' => [
4✔
UNCOV
113
                    ['@id' => $hydraPrefix.'Collection'],
4✔
UNCOV
114
                    [
4✔
UNCOV
115
                        'owl:equivalentClass' => [
4✔
UNCOV
116
                            'owl:onProperty' => ['@id' => $hydraPrefix.'member'],
4✔
UNCOV
117
                            'owl:allValuesFrom' => ['@id' => $prefixedShortName],
4✔
UNCOV
118
                        ],
4✔
UNCOV
119
                    ],
4✔
UNCOV
120
                ],
4✔
UNCOV
121
                $hydraPrefix.'supportedOperation' => $hydraCollectionOperations,
4✔
UNCOV
122
            ],
4✔
UNCOV
123
            $hydraPrefix.'title' => "get{$shortName}Collection",
4✔
UNCOV
124
            $hydraPrefix.'description' => "The collection of $shortName resources",
4✔
UNCOV
125
            $hydraPrefix.'readable' => true,
4✔
UNCOV
126
            $hydraPrefix.'writeable' => false,
4✔
UNCOV
127
        ];
4✔
128

UNCOV
129
        if ($resourceMetadata->getDeprecationReason()) {
4✔
UNCOV
130
            $entrypointProperty['owl:deprecated'] = true;
4✔
131
        }
132

UNCOV
133
        $entrypointProperties[] = $entrypointProperty;
4✔
134
    }
135

136
    /**
137
     * Gets a Hydra class.
138
     */
139
    private function getClass(string $resourceClass, ApiResource $resourceMetadata, string $shortName, string $prefixedShortName, array $context, string $hydraPrefix, ?ResourceMetadataCollection $resourceMetadataCollection = null): array
140
    {
141
        $description = $resourceMetadata->getDescription();
6✔
142
        $isDeprecated = $resourceMetadata->getDeprecationReason();
6✔
143

144
        $class = [
6✔
145
            '@id' => $prefixedShortName,
6✔
146
            '@type' => $hydraPrefix.'Class',
6✔
147
            $hydraPrefix.'title' => $shortName,
6✔
148
            $hydraPrefix.'supportedProperty' => $this->getHydraProperties($resourceClass, $resourceMetadata, $shortName, $prefixedShortName, $context, $hydraPrefix),
6✔
149
            $hydraPrefix.'supportedOperation' => $this->getHydraOperations(false, $resourceMetadataCollection, $hydraPrefix),
6✔
150
        ];
6✔
151

152
        if (null !== $description) {
6✔
153
            $class[$hydraPrefix.'description'] = $description;
6✔
154
        }
155

156
        if ($resourceMetadata instanceof ErrorResource) {
6✔
157
            $class['subClassOf'] = 'Error';
6✔
158
        }
159

160
        if ($isDeprecated) {
6✔
UNCOV
161
            $class['owl:deprecated'] = true;
4✔
162
        }
163

164
        return $class;
6✔
165
    }
166

167
    /**
168
     * Creates context for property metatata factories.
169
     */
170
    private function getPropertyMetadataFactoryContext(ApiResource $resourceMetadata): array
171
    {
172
        $normalizationGroups = $resourceMetadata->getNormalizationContext()[AbstractNormalizer::GROUPS] ?? null;
6✔
173
        $denormalizationGroups = $resourceMetadata->getDenormalizationContext()[AbstractNormalizer::GROUPS] ?? null;
6✔
174
        $propertyContext = [
6✔
175
            'normalization_groups' => $normalizationGroups,
6✔
176
            'denormalization_groups' => $denormalizationGroups,
6✔
177
        ];
6✔
178
        $propertyNameContext = [];
6✔
179

180
        if ($normalizationGroups) {
6✔
UNCOV
181
            $propertyNameContext['serializer_groups'] = $normalizationGroups;
4✔
182
        }
183

184
        if (!$denormalizationGroups) {
6✔
185
            return [$propertyNameContext, $propertyContext];
6✔
186
        }
187

UNCOV
188
        if (!isset($propertyNameContext['serializer_groups'])) {
4✔
189
            $propertyNameContext['serializer_groups'] = $denormalizationGroups;
×
190

191
            return [$propertyNameContext, $propertyContext];
×
192
        }
193

UNCOV
194
        foreach ($denormalizationGroups as $group) {
4✔
UNCOV
195
            $propertyNameContext['serializer_groups'][] = $group;
4✔
196
        }
197

UNCOV
198
        return [$propertyNameContext, $propertyContext];
4✔
199
    }
200

201
    /**
202
     * Gets Hydra properties.
203
     */
204
    private function getHydraProperties(string $resourceClass, ApiResource $resourceMetadata, string $shortName, string $prefixedShortName, array $context, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
205
    {
206
        $classes = [];
6✔
207

208
        $classes[$resourceClass] = true;
6✔
209
        foreach ($resourceMetadata->getOperations() as $operation) {
6✔
210
            /** @var Operation $operation */
211
            if (!$operation instanceof CollectionOperationInterface) {
6✔
212
                continue;
6✔
213
            }
214

215
            $inputMetadata = $operation->getInput();
6✔
216
            if (null !== $inputClass = $inputMetadata['class'] ?? null) {
6✔
UNCOV
217
                $classes[$inputClass] = true;
4✔
218
            }
219

220
            $outputMetadata = $operation->getOutput();
6✔
221
            if (null !== $outputClass = $outputMetadata['class'] ?? null) {
6✔
UNCOV
222
                $classes[$outputClass] = true;
4✔
223
            }
224
        }
225

226
        /** @var string[] $classes */
227
        $classes = array_keys($classes);
6✔
228
        $properties = [];
6✔
229
        [$propertyNameContext, $propertyContext] = $this->getPropertyMetadataFactoryContext($resourceMetadata);
6✔
230
        foreach ($classes as $class) {
6✔
231
            foreach ($this->propertyNameCollectionFactory->create($class, $propertyNameContext) as $propertyName) {
6✔
232
                $propertyMetadata = $this->propertyMetadataFactory->create($class, $propertyName, $propertyContext);
6✔
233

234
                if (true === $propertyMetadata->isIdentifier() && false === $propertyMetadata->isWritable()) {
6✔
235
                    continue;
6✔
236
                }
237

238
                if ($this->nameConverter) {
6✔
239
                    $propertyName = $this->nameConverter->normalize($propertyName, $class, self::FORMAT, $context);
6✔
240
                }
241

242
                if (false === $propertyMetadata->getHydra()) {
6✔
243
                    continue;
6✔
244
                }
245

246
                $properties[] = $this->getProperty($propertyMetadata, $propertyName, $prefixedShortName, $shortName, $hydraPrefix);
6✔
247
            }
248
        }
249

250
        return $properties;
6✔
251
    }
252

253
    /**
254
     * Gets Hydra operations.
255
     */
256
    private function getHydraOperations(bool $collection, ?ResourceMetadataCollection $resourceMetadataCollection = null, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
257
    {
258
        $hydraOperations = [];
6✔
259
        foreach ($resourceMetadataCollection as $resourceMetadata) {
6✔
260
            foreach ($resourceMetadata->getOperations() as $operation) {
6✔
261
                if (true === $operation->getHideHydraOperation()) {
6✔
262
                    continue;
6✔
263
                }
264

265
                if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) {
6✔
266
                    continue;
6✔
267
                }
268

269
                $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix);
6✔
270
            }
271
        }
272

273
        return $hydraOperations;
6✔
274
    }
275

276
    /**
277
     * Gets and populates if applicable a Hydra operation.
278
     */
279
    private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix): array
280
    {
281
        $method = $operation->getMethod() ?: 'GET';
6✔
282

283
        $hydraOperation = $operation->getHydraContext() ?? [];
6✔
284
        if ($operation->getDeprecationReason()) {
6✔
UNCOV
285
            $hydraOperation['owl:deprecated'] = true;
4✔
286
        }
287

288
        $shortName = $operation->getShortName();
6✔
289
        $inputMetadata = $operation->getInput() ?? [];
6✔
290
        $outputMetadata = $operation->getOutput() ?? [];
6✔
291

292
        $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false;
6✔
293
        $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false;
6✔
294

295
        if ('GET' === $method && $operation instanceof CollectionOperationInterface) {
6✔
UNCOV
296
            $hydraOperation += [
4✔
UNCOV
297
                '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'],
4✔
UNCOV
298
                $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.",
4✔
UNCOV
299
                'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection',
4✔
UNCOV
300
            ];
4✔
301
        } elseif ('GET' === $method) {
6✔
302
            $hydraOperation += [
6✔
303
                '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'],
6✔
304
                $hydraPrefix.'description' => "Retrieves a $shortName resource.",
6✔
305
                'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
6✔
306
            ];
6✔
UNCOV
307
        } elseif ('PATCH' === $method) {
4✔
UNCOV
308
            $hydraOperation += [
4✔
UNCOV
309
                '@type' => $hydraPrefix.'Operation',
4✔
UNCOV
310
                $hydraPrefix.'description' => "Updates the $shortName resource.",
4✔
UNCOV
311
                'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
4✔
UNCOV
312
                'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
4✔
UNCOV
313
            ];
4✔
314

UNCOV
315
            if (null !== $inputClass) {
4✔
UNCOV
316
                $possibleValue = [];
4✔
UNCOV
317
                foreach ($operation->getInputFormats() as $mimeTypes) {
4✔
UNCOV
318
                    foreach ($mimeTypes as $mimeType) {
4✔
UNCOV
319
                        $possibleValue[] = $mimeType;
4✔
320
                    }
321
                }
322

UNCOV
323
                $hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]];
4✔
324
            }
UNCOV
325
        } elseif ('POST' === $method) {
4✔
UNCOV
326
            $hydraOperation += [
4✔
UNCOV
327
                '@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'],
4✔
UNCOV
328
                $hydraPrefix.'description' => "Creates a $shortName resource.",
4✔
UNCOV
329
                'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
4✔
UNCOV
330
                'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
4✔
UNCOV
331
            ];
4✔
UNCOV
332
        } elseif ('PUT' === $method) {
4✔
UNCOV
333
            $hydraOperation += [
4✔
UNCOV
334
                '@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'],
4✔
UNCOV
335
                $hydraPrefix.'description' => "Replaces the $shortName resource.",
4✔
UNCOV
336
                'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName,
4✔
UNCOV
337
                'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName,
4✔
UNCOV
338
            ];
4✔
UNCOV
339
        } elseif ('DELETE' === $method) {
4✔
UNCOV
340
            $hydraOperation += [
4✔
UNCOV
341
                '@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'],
4✔
UNCOV
342
                $hydraPrefix.'description' => "Deletes the $shortName resource.",
4✔
UNCOV
343
                'returns' => 'owl:Nothing',
4✔
UNCOV
344
            ];
4✔
345
        }
346

347
        $hydraOperation[$hydraPrefix.'method'] ??= $method;
6✔
348
        $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : '');
6✔
349

350
        ksort($hydraOperation);
6✔
351

352
        return $hydraOperation;
6✔
353
    }
354

355
    /**
356
     * Gets the range of the property.
357
     */
358
    private function getRange(ApiProperty $propertyMetadata): array|string|null
359
    {
360
        $jsonldContext = $propertyMetadata->getJsonldContext();
6✔
361

362
        if (isset($jsonldContext['@type'])) {
6✔
363
            return $jsonldContext['@type'];
6✔
364
        }
365

366
        $types = [];
6✔
367

368
        if (method_exists(PropertyInfoExtractor::class, 'getType')) {
6✔
369
            $nativeType = $propertyMetadata->getNativeType();
6✔
370
            if (null === $nativeType) {
6✔
UNCOV
371
                return null;
4✔
372
            }
373

374
            /** @var Type|null $collectionValueType */
375
            $collectionValueType = null;
6✔
376
            $typeIsCollection = static function (Type $type) use (&$typeIsCollection, &$collectionValueType): bool {
6✔
377
                return match (true) {
378
                    $type instanceof CollectionType => null !== $collectionValueType = $type->getCollectionValueType(),
6✔
379
                    $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsCollection),
6✔
380
                    $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsCollection),
6✔
381
                    default => false,
6✔
382
                };
383
            };
6✔
384

385
            if ($nativeType->isSatisfiedBy($typeIsCollection)) {
6✔
386
                $nativeType = $collectionValueType;
6✔
387
            }
388

389
            // Check for specific types after potentially unwrapping the collection
390
            if (null === $nativeType) {
6✔
391
                return null; // Should not happen if collection had a value type, but safety check
×
392
            }
393

394
            if ($nativeType->isIdentifiedBy(TypeIdentifier::STRING)) {
6✔
395
                $types[] = 'xmls:string';
6✔
396
            }
397

398
            if ($nativeType->isIdentifiedBy(TypeIdentifier::INT)) {
6✔
399
                $types[] = 'xmls:integer';
6✔
400
            }
401

402
            if ($nativeType->isIdentifiedBy(TypeIdentifier::FLOAT)) {
6✔
UNCOV
403
                $types[] = 'xmls:decimal';
4✔
404
            }
405

406
            if ($nativeType->isIdentifiedBy(TypeIdentifier::BOOL)) {
6✔
UNCOV
407
                $types[] = 'xmls:boolean';
4✔
408
            }
409

410
            if ($nativeType->isIdentifiedBy(\DateTimeInterface::class)) {
6✔
UNCOV
411
                $types[] = 'xmls:dateTime';
4✔
412
            }
413

414
            /** @var class-string|null $className */
415
            $className = null;
6✔
416

417
            $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool {
6✔
418
                return match (true) {
419
                    $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass),
6✔
420
                    $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass),
6✔
421
                    default => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()),
6✔
422
                };
423
            };
6✔
424

425
            if ($nativeType->isSatisfiedBy($typeIsResourceClass) && $className) {
6✔
UNCOV
426
                $resourceMetadata = $this->resourceMetadataFactory->create($className);
4✔
UNCOV
427
                $operation = $resourceMetadata->getOperation();
4✔
428

UNCOV
429
                if (!$operation instanceof HttpOperation || !$operation->getTypes()) {
4✔
UNCOV
430
                    if (!\in_array("#{$operation->getShortName()}", $types, true)) {
4✔
UNCOV
431
                        $types[] = "#{$operation->getShortName()}";
4✔
432
                    }
433
                } else {
UNCOV
434
                    $types = array_unique(array_merge($types, $operation->getTypes()));
4✔
435
                }
436
            }
437
        // TODO: remove in 5.x
438
        } else {
439
            $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? [];
×
440

441
            foreach ($builtInTypes as $type) {
×
442
                if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueTypes()[0] ?? null) {
×
443
                    $type = $collectionType;
×
444
                }
445

446
                switch ($type->getBuiltinType()) {
×
447
                    case LegacyType::BUILTIN_TYPE_STRING:
448
                        if (!\in_array('xmls:string', $types, true)) {
×
449
                            $types[] = 'xmls:string';
×
450
                        }
451
                        break;
×
452
                    case LegacyType::BUILTIN_TYPE_INT:
453
                        if (!\in_array('xmls:integer', $types, true)) {
×
454
                            $types[] = 'xmls:integer';
×
455
                        }
456
                        break;
×
457
                    case LegacyType::BUILTIN_TYPE_FLOAT:
458
                        if (!\in_array('xmls:decimal', $types, true)) {
×
459
                            $types[] = 'xmls:decimal';
×
460
                        }
461
                        break;
×
462
                    case LegacyType::BUILTIN_TYPE_BOOL:
463
                        if (!\in_array('xmls:boolean', $types, true)) {
×
464
                            $types[] = 'xmls:boolean';
×
465
                        }
466
                        break;
×
467
                    case LegacyType::BUILTIN_TYPE_OBJECT:
468
                        if (null === $className = $type->getClassName()) {
×
469
                            continue 2;
×
470
                        }
471

472
                        if (is_a($className, \DateTimeInterface::class, true)) {
×
473
                            if (!\in_array('xmls:dateTime', $types, true)) {
×
474
                                $types[] = 'xmls:dateTime';
×
475
                            }
476
                            break;
×
477
                        }
478

479
                        if ($this->resourceClassResolver->isResourceClass($className)) {
×
480
                            $resourceMetadata = $this->resourceMetadataFactory->create($className);
×
481
                            $operation = $resourceMetadata->getOperation();
×
482

483
                            if (!$operation instanceof HttpOperation || !$operation->getTypes()) {
×
484
                                if (!\in_array("#{$operation->getShortName()}", $types, true)) {
×
485
                                    $types[] = "#{$operation->getShortName()}";
×
486
                                }
487
                                break;
×
488
                            }
489

490
                            $types = array_unique(array_merge($types, $operation->getTypes()));
×
491
                            break;
×
492
                        }
493
                }
494
            }
495
        }
496

497
        if ([] === $types) {
6✔
498
            return null;
6✔
499
        }
500

501
        $types = array_unique($types);
6✔
502

503
        return 1 === \count($types) ? $types[0] : $types;
6✔
504
    }
505

506
    private function isSingleRelation(ApiProperty $propertyMetadata): bool
507
    {
508
        if (method_exists(PropertyInfoExtractor::class, 'getType')) {
6✔
509
            $nativeType = $propertyMetadata->getNativeType();
6✔
510
            if (null === $nativeType) {
6✔
UNCOV
511
                return false;
4✔
512
            }
513

514
            if ($nativeType instanceof CollectionType) {
6✔
515
                return false;
6✔
516
            }
517

518
            $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass): bool {
6✔
519
                return match (true) {
520
                    $type instanceof CollectionType => false,
6✔
521
                    $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass),
6✔
522
                    $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass),
6✔
523
                    default => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($type->getClassName()),
6✔
524
                };
525
            };
6✔
526

527
            return $nativeType->isSatisfiedBy($typeIsResourceClass);
6✔
528
        }
529

530
        // TODO: remove in 5.x
531
        $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? [];
×
532

533
        foreach ($builtInTypes as $type) {
×
534
            $className = $type->getClassName();
×
535
            if (
536
                !$type->isCollection()
×
537
                && null !== $className
×
538
                && $this->resourceClassResolver->isResourceClass($className)
×
539
            ) {
540
                return true;
×
541
            }
542
        }
543

544
        return false;
×
545
    }
546

547
    /**
548
     * Builds the classes array.
549
     */
550
    private function getClasses(array $entrypointProperties, array $classes, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
551
    {
552
        if ($this->entrypointEnabled) {
6✔
553
            $classes[] = [
6✔
554
                '@id' => '#Entrypoint',
6✔
555
                '@type' => $hydraPrefix.'Class',
6✔
556
                $hydraPrefix.'title' => 'Entrypoint',
6✔
557
                $hydraPrefix.'supportedProperty' => $entrypointProperties,
6✔
558
                $hydraPrefix.'supportedOperation' => [
6✔
559
                    '@type' => $hydraPrefix.'Operation',
6✔
560
                    $hydraPrefix.'method' => 'GET',
6✔
561
                    $hydraPrefix.'title' => 'index',
6✔
562
                    $hydraPrefix.'description' => 'The API Entrypoint.',
6✔
563
                    $hydraPrefix.'returns' => 'Entrypoint',
6✔
564
                ],
6✔
565
            ];
6✔
566
        }
567

568
        $classes[] = [
6✔
569
            '@id' => '#ConstraintViolationList',
6✔
570
            '@type' => $hydraPrefix.'Class',
6✔
571
            $hydraPrefix.'title' => 'ConstraintViolationList',
6✔
572
            $hydraPrefix.'description' => 'A constraint violation List.',
6✔
573
            $hydraPrefix.'supportedProperty' => [
6✔
574
                [
6✔
575
                    '@type' => $hydraPrefix.'SupportedProperty',
6✔
576
                    $hydraPrefix.'property' => [
6✔
577
                        '@id' => '#ConstraintViolationList/propertyPath',
6✔
578
                        '@type' => 'rdf:Property',
6✔
579
                        'rdfs:label' => 'propertyPath',
6✔
580
                        'domain' => '#ConstraintViolationList',
6✔
581
                        'range' => 'xmls:string',
6✔
582
                    ],
6✔
583
                    $hydraPrefix.'title' => 'propertyPath',
6✔
584
                    $hydraPrefix.'description' => 'The property path of the violation',
6✔
585
                    $hydraPrefix.'readable' => true,
6✔
586
                    $hydraPrefix.'writeable' => false,
6✔
587
                ],
6✔
588
                [
6✔
589
                    '@type' => $hydraPrefix.'SupportedProperty',
6✔
590
                    $hydraPrefix.'property' => [
6✔
591
                        '@id' => '#ConstraintViolationList/message',
6✔
592
                        '@type' => 'rdf:Property',
6✔
593
                        'rdfs:label' => 'message',
6✔
594
                        'domain' => '#ConstraintViolationList',
6✔
595
                        'range' => 'xmls:string',
6✔
596
                    ],
6✔
597
                    $hydraPrefix.'title' => 'message',
6✔
598
                    $hydraPrefix.'description' => 'The message associated with the violation',
6✔
599
                    $hydraPrefix.'readable' => true,
6✔
600
                    $hydraPrefix.'writeable' => false,
6✔
601
                ],
6✔
602
            ],
6✔
603
        ];
6✔
604

605
        return $classes;
6✔
606
    }
607

608
    /**
609
     * Gets a property definition.
610
     */
611
    private function getProperty(ApiProperty $propertyMetadata, string $propertyName, string $prefixedShortName, string $shortName, string $hydraPrefix): array
612
    {
613
        if ($iri = $propertyMetadata->getIris()) {
6✔
UNCOV
614
            $iri = 1 === (is_countable($iri) ? \count($iri) : 0) ? $iri[0] : $iri;
4✔
615
        }
616

617
        if (!isset($iri)) {
6✔
618
            $iri = "#$shortName/$propertyName";
6✔
619
        }
620

621
        $propertyData = ($propertyMetadata->getJsonldContext()[$hydraPrefix.'property'] ?? []) + [
6✔
622
            '@id' => $iri,
6✔
623
            '@type' => false === $propertyMetadata->isReadableLink() ? $hydraPrefix.'Link' : 'rdf:Property',
6✔
624
            'domain' => $prefixedShortName,
6✔
625
            'label' => $propertyName,
6✔
626
        ];
6✔
627

628
        if (!isset($propertyData['owl:deprecated']) && $propertyMetadata->getDeprecationReason()) {
6✔
UNCOV
629
            $propertyData['owl:deprecated'] = true;
4✔
630
        }
631

632
        if (!isset($propertyData['owl:maxCardinality']) && $this->isSingleRelation($propertyMetadata)) {
6✔
UNCOV
633
            $propertyData['owl:maxCardinality'] = 1;
4✔
634
        }
635

636
        if (!isset($propertyData['range']) && null !== $range = $this->getRange($propertyMetadata)) {
6✔
637
            $propertyData['range'] = $range;
6✔
638
        }
639

640
        $property = [
6✔
641
            '@type' => $hydraPrefix.'SupportedProperty',
6✔
642
            $hydraPrefix.'property' => $propertyData,
6✔
643
            $hydraPrefix.'title' => $propertyName,
6✔
644
            $hydraPrefix.'required' => $propertyMetadata->isRequired() ?? false,
6✔
645
            $hydraPrefix.'readable' => $propertyMetadata->isReadable(),
6✔
646
            $hydraPrefix.'writeable' => $propertyMetadata->isWritable() || $propertyMetadata->isInitializable(),
6✔
647
        ];
6✔
648

649
        if (null !== $description = $propertyMetadata->getDescription()) {
6✔
650
            $property[$hydraPrefix.'description'] = $description;
6✔
651
        }
652

653
        return $property;
6✔
654
    }
655

656
    /**
657
     * Computes the documentation.
658
     */
659
    private function computeDoc(Documentation $object, array $classes, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
660
    {
661
        $doc = ['@context' => $this->getContext($hydraPrefix), '@id' => $this->urlGenerator->generate('api_doc', ['_format' => self::FORMAT]), '@type' => $hydraPrefix.'ApiDocumentation'];
6✔
662

663
        if ('' !== $object->getTitle()) {
6✔
664
            $doc[$hydraPrefix.'title'] = $object->getTitle();
6✔
665
        }
666

667
        if ('' !== $object->getDescription()) {
6✔
668
            $doc[$hydraPrefix.'description'] = $object->getDescription();
6✔
669
        }
670

671
        if ($this->entrypointEnabled) {
6✔
672
            $doc[$hydraPrefix.'entrypoint'] = $this->urlGenerator->generate('api_entrypoint');
6✔
673
        }
674
        $doc[$hydraPrefix.'supportedClass'] = $classes;
6✔
675

676
        return $doc;
6✔
677
    }
678

679
    /**
680
     * Builds the JSON-LD context for the API documentation.
681
     */
682
    private function getContext(string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
683
    {
684
        return [
6✔
685
            HYDRA_CONTEXT,
6✔
686
            [
6✔
687
                '@vocab' => $this->urlGenerator->generate('api_doc', ['_format' => self::FORMAT], UrlGeneratorInterface::ABS_URL).'#',
6✔
688
                'hydra' => ContextBuilderInterface::HYDRA_NS,
6✔
689
                'rdf' => ContextBuilderInterface::RDF_NS,
6✔
690
                'rdfs' => ContextBuilderInterface::RDFS_NS,
6✔
691
                'xmls' => ContextBuilderInterface::XML_NS,
6✔
692
                'owl' => ContextBuilderInterface::OWL_NS,
6✔
693
                'schema' => ContextBuilderInterface::SCHEMA_ORG_NS,
6✔
694
                'domain' => ['@id' => 'rdfs:domain', '@type' => '@id'],
6✔
695
                'range' => ['@id' => 'rdfs:range', '@type' => '@id'],
6✔
696
                'subClassOf' => ['@id' => 'rdfs:subClassOf', '@type' => '@id'],
6✔
697
            ],
6✔
698
        ];
6✔
699
    }
700

701
    /**
702
     * {@inheritdoc}
703
     */
704
    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
705
    {
706
        return self::FORMAT === $format && $data instanceof Documentation;
6✔
707
    }
708

709
    public function getSupportedTypes($format): array
710
    {
711
        return self::FORMAT === $format ? [Documentation::class => true] : [];
1,062✔
712
    }
713
}
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