Completed
Push — master ( c304b1...e3d50f )
by Antoine
17s queued 11s
created

DocumentationNormalizer::getHydraProperties()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 9
nc 4
nop 4
1
<?php
2
3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <[email protected]>
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\Core\Hydra\Serializer;
15
16
use ApiPlatform\Core\Api\OperationMethodResolverInterface;
17
use ApiPlatform\Core\Api\OperationType;
18
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
19
use ApiPlatform\Core\Api\UrlGeneratorInterface;
20
use ApiPlatform\Core\Documentation\Documentation;
21
use ApiPlatform\Core\JsonLd\ContextBuilderInterface;
22
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
24
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
25
use ApiPlatform\Core\Metadata\Property\SubresourceMetadata;
26
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
27
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
28
use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface;
29
use Symfony\Component\PropertyInfo\Type;
30
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
31
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
32
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
33
34
/**
35
 * Creates a machine readable Hydra API documentation.
36
 *
37
 * @author Kévin Dunglas <[email protected]>
38
 */
39
final class DocumentationNormalizer implements NormalizerInterface
40
{
41
    const FORMAT = 'jsonld';
42
43
    private $resourceMetadataFactory;
44
    private $propertyNameCollectionFactory;
45
    private $propertyMetadataFactory;
46
    private $resourceClassResolver;
47
    private $operationMethodResolver;
48
    private $urlGenerator;
49
    private $subresourceOperationFactory;
50
    private $nameConverter;
51
52
    public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, OperationMethodResolverInterface $operationMethodResolver, UrlGeneratorInterface $urlGenerator, SubresourceOperationFactoryInterface $subresourceOperationFactory = null, NameConverterInterface $nameConverter = null)
53
    {
54
        $this->resourceMetadataFactory = $resourceMetadataFactory;
55
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
56
        $this->propertyMetadataFactory = $propertyMetadataFactory;
57
        $this->resourceClassResolver = $resourceClassResolver;
58
        $this->operationMethodResolver = $operationMethodResolver;
59
        $this->urlGenerator = $urlGenerator;
60
        $this->subresourceOperationFactory = $subresourceOperationFactory;
61
        $this->nameConverter = $nameConverter;
62
    }
63
64
    /**
65
     * {@inheritdoc}
66
     */
67
    public function normalize($object, $format = null, array $context = [])
68
    {
69
        $classes = [];
70
        $entrypointProperties = [];
71
72
        foreach ($object->getResourceNameCollection() as $resourceClass) {
73
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
74
            $shortName = $resourceMetadata->getShortName();
75
            $prefixedShortName = $resourceMetadata->getIri() ?? "#$shortName";
76
77
            $this->populateEntrypointProperties($resourceClass, $resourceMetadata, $shortName, $prefixedShortName, $entrypointProperties);
78
            $classes[] = $this->getClass($resourceClass, $resourceMetadata, $shortName, $prefixedShortName);
79
        }
80
81
        return $this->computeDoc($object, $this->getClasses($entrypointProperties, $classes));
82
    }
83
84
    /**
85
     * Populates entrypoint properties.
86
     *
87
     * @param string           $resourceClass
88
     * @param ResourceMetadata $resourceMetadata
89
     * @param string           $shortName
90
     * @param string           $prefixedShortName
91
     * @param array            $entrypointProperties
92
     */
93
    private function populateEntrypointProperties(string $resourceClass, ResourceMetadata $resourceMetadata, string $shortName, string $prefixedShortName, array &$entrypointProperties)
94
    {
95
        $hydraCollectionOperations = $this->getHydraOperations($resourceClass, $resourceMetadata, $prefixedShortName, true);
96
        if (empty($hydraCollectionOperations)) {
97
            return;
98
        }
99
100
        $entrypointProperties[] = [
101
            '@type' => 'hydra:SupportedProperty',
102
            'hydra:property' => [
103
                '@id' => sprintf('#Entrypoint/%s', lcfirst($shortName)),
104
                '@type' => 'hydra:Link',
105
                'domain' => '#Entrypoint',
106
                'rdfs:label' => "The collection of $shortName resources",
107
                'rdfs:range' => [
108
                    ['@id' => 'hydra:Collection'],
109
                    [
110
                        'owl:equivalentClass' => [
111
                            'owl:onProperty' => ['@id' => 'hydra:member'],
112
                            'owl:allValuesFrom' => ['@id' => $prefixedShortName],
113
                        ],
114
                    ],
115
                ],
116
                'hydra:supportedOperation' => $hydraCollectionOperations,
117
            ],
118
            'hydra:title' => "The collection of $shortName resources",
119
            'hydra:readable' => true,
120
            'hydra:writable' => false,
121
        ];
122
    }
123
124
    /**
125
     * Gets a Hydra class.
126
     *
127
     * @param string           $resourceClass
128
     * @param ResourceMetadata $resourceMetadata
129
     * @param string           $shortName
130
     * @param string           $prefixedShortName
131
     *
132
     * @return array
133
     */
134
    private function getClass(string $resourceClass, ResourceMetadata $resourceMetadata, string $shortName, string $prefixedShortName): array
135
    {
136
        $class = [
137
            '@id' => $prefixedShortName,
138
            '@type' => 'hydra:Class',
139
            'rdfs:label' => $shortName,
140
            'hydra:title' => $shortName,
141
            'hydra:supportedProperty' => $this->getHydraProperties($resourceClass, $resourceMetadata, $shortName, $prefixedShortName),
142
            'hydra:supportedOperation' => $this->getHydraOperations($resourceClass, $resourceMetadata, $prefixedShortName, false),
143
        ];
144
145
        if (null !== $description = $resourceMetadata->getDescription()) {
146
            $class['hydra:description'] = $description;
147
        }
148
149
        return $class;
150
    }
151
152
    /**
153
     * Gets the context for the property name factory.
154
     *
155
     * @param ResourceMetadata $resourceMetadata
156
     *
157
     * @return array
158
     */
159
    private function getPropertyNameCollectionFactoryContext(ResourceMetadata $resourceMetadata): array
160
    {
161
        $attributes = $resourceMetadata->getAttributes();
162
        $context = [];
163
164
        if (isset($attributes['normalization_context'][AbstractNormalizer::GROUPS])) {
165
            $context['serializer_groups'] = $attributes['normalization_context'][AbstractNormalizer::GROUPS];
166
        }
167
168
        if (isset($attributes['denormalization_context'][AbstractNormalizer::GROUPS])) {
169
            if (isset($context['serializer_groups'])) {
170
                foreach ($attributes['denormalization_context'][AbstractNormalizer::GROUPS] as $groupName) {
171
                    $context['serializer_groups'][] = $groupName;
172
                }
173
            } else {
174
                $context['serializer_groups'] = $attributes['denormalization_context'][AbstractNormalizer::GROUPS];
175
            }
176
        }
177
178
        return $context;
179
    }
180
181
    /**
182
     * Gets Hydra properties.
183
     *
184
     * @param string           $resourceClass
185
     * @param ResourceMetadata $resourceMetadata
186
     * @param string           $shortName
187
     * @param string           $prefixedShortName
188
     *
189
     * @return array
190
     */
191
    private function getHydraProperties(string $resourceClass, ResourceMetadata $resourceMetadata, string $shortName, string $prefixedShortName): array
192
    {
193
        $properties = [];
194
        foreach ($this->propertyNameCollectionFactory->create($resourceClass, $this->getPropertyNameCollectionFactoryContext($resourceMetadata)) as $propertyName) {
195
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
196
            if (true === $propertyMetadata->isIdentifier() && false === $propertyMetadata->isWritable()) {
197
                continue;
198
            }
199
200
            if ($this->nameConverter) {
201
                $propertyName = $this->nameConverter->normalize($propertyName);
202
            }
203
204
            $properties[] = $this->getProperty($propertyMetadata, $propertyName, $prefixedShortName, $shortName);
205
        }
206
207
        return $properties;
208
    }
209
210
    /**
211
     * Gets Hydra operations.
212
     *
213
     * @param string           $resourceClass
214
     * @param ResourceMetadata $resourceMetadata
215
     * @param string           $prefixedShortName
216
     * @param bool             $collection
217
     *
218
     * @return array
219
     */
220
    private function getHydraOperations(string $resourceClass, ResourceMetadata $resourceMetadata, string $prefixedShortName, bool $collection): array
221
    {
222
        if (null === $operations = $collection ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) {
223
            return [];
224
        }
225
226
        $hydraOperations = [];
227
        foreach ($operations as $operationName => $operation) {
228
            $hydraOperations[] = $this->getHydraOperation($resourceClass, $resourceMetadata, $operationName, $operation, $prefixedShortName, $collection ? OperationType::COLLECTION : OperationType::ITEM);
229
        }
230
231
        if (null !== $this->subresourceOperationFactory) {
232
            foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $operation) {
233
                $subresourceMetadata = $this->resourceMetadataFactory->create($operation['resource_class']);
234
                $propertyMetadata = $this->propertyMetadataFactory->create(end($operation['identifiers'])[1], $operation['property']);
235
                $hydraOperations[] = $this->getHydraOperation($resourceClass, $subresourceMetadata, $operation['route_name'], $operation, "#{$subresourceMetadata->getShortName()}", OperationType::SUBRESOURCE, $propertyMetadata->getSubresource());
236
            }
237
        }
238
239
        return $hydraOperations;
240
    }
241
242
    /**
243
     * Gets and populates if applicable a Hydra operation.
244
     *
245
     * @param string              $resourceClass
246
     * @param ResourceMetadata    $resourceMetadata
247
     * @param string              $operationName
248
     * @param array               $operation
249
     * @param string              $prefixedShortName
250
     * @param string              $operationType
251
     * @param SubresourceMetadata $subresourceMetadata
252
     *
253
     * @return array
254
     */
255
    private function getHydraOperation(string $resourceClass, ResourceMetadata $resourceMetadata, string $operationName, array $operation, string $prefixedShortName, string $operationType, SubresourceMetadata $subresourceMetadata = null): array
256
    {
257
        if (OperationType::COLLECTION === $operationType) {
258
            $method = $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName);
259
        } elseif (OperationType::ITEM === $operationType) {
260
            $method = $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName);
261
        } else {
262
            $method = 'GET';
263
        }
264
265
        $hydraOperation = $operation['hydra_context'] ?? [];
266
        $shortName = $resourceMetadata->getShortName();
267
268
        if ('GET' === $method && OperationType::COLLECTION === $operationType) {
269
            $hydraOperation += [
270
                '@type' => ['hydra:Operation', 'schema:FindAction'],
271
                'hydra:title' => "Retrieves the collection of $shortName resources.",
272
                'returns' => 'hydra:Collection',
273
            ];
274
        } elseif ('GET' === $method && OperationType::SUBRESOURCE === $operationType) {
275
            $hydraOperation += [
276
                '@type' => ['hydra:Operation', 'schema:FindAction'],
277
                'hydra:title' => $subresourceMetadata && $subresourceMetadata->isCollection() ? "Retrieves the collection of $shortName resources." : "Retrieves a $shortName resource.",
278
                'returns' => "#$shortName",
279
            ];
280
        } elseif ('GET' === $method) {
281
            $hydraOperation += [
282
                '@type' => ['hydra:Operation', 'schema:FindAction'],
283
                'hydra:title' => "Retrieves $shortName resource.",
284
                'returns' => $prefixedShortName,
285
            ];
286
        } elseif ('PATCH' === $method) {
287
            $hydraOperation += [
288
                '@type' => 'hydra:Operation',
289
                'hydra:title' => "Updates the $shortName resource.",
290
                'returns' => $prefixedShortName,
291
                'expects' => $prefixedShortName,
292
            ];
293
        } elseif ('POST' === $method) {
294
            $hydraOperation += [
295
                '@type' => ['hydra:Operation', 'schema:CreateAction'],
296
                'hydra:title' => "Creates a $shortName resource.",
297
                'returns' => $prefixedShortName,
298
                'expects' => $prefixedShortName,
299
            ];
300
        } elseif ('PUT' === $method) {
301
            $hydraOperation += [
302
                '@type' => ['hydra:Operation', 'schema:ReplaceAction'],
303
                'hydra:title' => "Replaces the $shortName resource.",
304
                'returns' => $prefixedShortName,
305
                'expects' => $prefixedShortName,
306
            ];
307
        } elseif ('DELETE' === $method) {
308
            $hydraOperation += [
309
                '@type' => ['hydra:Operation', 'schema:DeleteAction'],
310
                'hydra:title' => "Deletes the $shortName resource.",
311
                'returns' => 'owl:Nothing',
312
            ];
313
        }
314
315
        $hydraOperation['hydra:method'] ?? $hydraOperation['hydra:method'] = $method;
316
317
        if (!isset($hydraOperation['rdfs:label']) && isset($hydraOperation['hydra:title'])) {
318
            $hydraOperation['rdfs:label'] = $hydraOperation['hydra:title'];
319
        }
320
321
        ksort($hydraOperation);
322
323
        return $hydraOperation;
324
    }
325
326
    /**
327
     * Gets the range of the property.
328
     *
329
     * @param PropertyMetadata $propertyMetadata
330
     *
331
     * @return string|null
332
     */
333
    private function getRange(PropertyMetadata $propertyMetadata)
334
    {
335
        $jsonldContext = $propertyMetadata->getAttributes()['jsonld_context'] ?? [];
336
337
        if (isset($jsonldContext['@type'])) {
338
            return $jsonldContext['@type'];
339
        }
340
341
        if (null === $type = $propertyMetadata->getType()) {
342
            return null;
343
        }
344
345
        if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueType()) {
346
            $type = $collectionType;
347
        }
348
349
        switch ($type->getBuiltinType()) {
350
            case Type::BUILTIN_TYPE_STRING:
351
                return 'xmls:string';
352
            case Type::BUILTIN_TYPE_INT:
353
                return 'xmls:integer';
354
            case Type::BUILTIN_TYPE_FLOAT:
355
                return 'xmls:decimal';
356
            case Type::BUILTIN_TYPE_BOOL:
357
                return 'xmls:boolean';
358
            case Type::BUILTIN_TYPE_OBJECT:
359
                if (null === $className = $type->getClassName()) {
360
                    return null;
361
                }
362
363
                if (is_a($className, \DateTimeInterface::class, true)) {
364
                    return 'xmls:dateTime';
365
                }
366
367
                if ($this->resourceClassResolver->isResourceClass($className)) {
368
                    $resourceMetadata = $this->resourceMetadataFactory->create($className);
369
370
                    return $resourceMetadata->getIri() ?? "#{$resourceMetadata->getShortName()}";
371
                }
372
                break;
373
        }
374
375
        return null;
376
    }
377
378
    /**
379
     * Builds the classes array.
380
     *
381
     * @param array $entrypointProperties
382
     * @param array $classes
383
     *
384
     * @return array
385
     */
386
    private function getClasses(array $entrypointProperties, array $classes): array
387
    {
388
        $classes[] = [
389
            '@id' => '#Entrypoint',
390
            '@type' => 'hydra:Class',
391
            'hydra:title' => 'The API entrypoint',
392
            'hydra:supportedProperty' => $entrypointProperties,
393
            'hydra:supportedOperation' => [
394
                '@type' => 'hydra:Operation',
395
                'hydra:method' => 'GET',
396
                'rdfs:label' => 'The API entrypoint.',
397
                'returns' => '#EntryPoint',
398
            ],
399
        ];
400
401
        // Constraint violation
402
        $classes[] = [
403
            '@id' => '#ConstraintViolation',
404
            '@type' => 'hydra:Class',
405
            'hydra:title' => 'A constraint violation',
406
            'hydra:supportedProperty' => [
407
                [
408
                    '@type' => 'hydra:SupportedProperty',
409
                    'hydra:property' => [
410
                        '@id' => '#ConstraintViolation/propertyPath',
411
                        '@type' => 'rdf:Property',
412
                        'rdfs:label' => 'propertyPath',
413
                        'domain' => '#ConstraintViolation',
414
                        'range' => 'xmls:string',
415
                    ],
416
                    'hydra:title' => 'propertyPath',
417
                    'hydra:description' => 'The property path of the violation',
418
                    'hydra:readable' => true,
419
                    'hydra:writable' => false,
420
                ],
421
                [
422
                    '@type' => 'hydra:SupportedProperty',
423
                    'hydra:property' => [
424
                        '@id' => '#ConstraintViolation/message',
425
                        '@type' => 'rdf:Property',
426
                        'rdfs:label' => 'message',
427
                        'domain' => '#ConstraintViolation',
428
                        'range' => 'xmls:string',
429
                    ],
430
                    'hydra:title' => 'message',
431
                    'hydra:description' => 'The message associated with the violation',
432
                    'hydra:readable' => true,
433
                    'hydra:writable' => false,
434
                ],
435
            ],
436
        ];
437
438
        // Constraint violation list
439
        $classes[] = [
440
            '@id' => '#ConstraintViolationList',
441
            '@type' => 'hydra:Class',
442
            'subClassOf' => 'hydra:Error',
443
            'hydra:title' => 'A constraint violation list',
444
            'hydra:supportedProperty' => [
445
                [
446
                    '@type' => 'hydra:SupportedProperty',
447
                    'hydra:property' => [
448
                        '@id' => '#ConstraintViolationList/violations',
449
                        '@type' => 'rdf:Property',
450
                        'rdfs:label' => 'violations',
451
                        'domain' => '#ConstraintViolationList',
452
                        'range' => '#ConstraintViolation',
453
                    ],
454
                    'hydra:title' => 'violations',
455
                    'hydra:description' => 'The violations',
456
                    'hydra:readable' => true,
457
                    'hydra:writable' => false,
458
                ],
459
            ],
460
        ];
461
462
        return $classes;
463
    }
464
465
    /**
466
     * Gets a property definition.
467
     *
468
     * @param PropertyMetadata $propertyMetadata
469
     * @param string           $propertyName
470
     * @param string           $prefixedShortName
471
     * @param string           $shortName
472
     *
473
     * @return array
474
     */
475
    private function getProperty(PropertyMetadata $propertyMetadata, string $propertyName, string $prefixedShortName, string $shortName): array
476
    {
477
        $propertyData = [
478
            '@id' => $propertyMetadata->getIri() ?? "#$shortName/$propertyName",
479
            '@type' => $propertyMetadata->isReadableLink() ? 'rdf:Property' : 'hydra:Link',
480
            'rdfs:label' => $propertyName,
481
            'domain' => $prefixedShortName,
482
        ];
483
484
        $type = $propertyMetadata->getType();
485
486
        if (null !== $type && !$type->isCollection() && (null !== $className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) {
487
            $propertyData['owl:maxCardinality'] = 1;
488
        }
489
490
        $property = [
491
            '@type' => 'hydra:SupportedProperty',
492
            'hydra:property' => $propertyData,
493
            'hydra:title' => $propertyName,
494
            'hydra:required' => $propertyMetadata->isRequired(),
495
            'hydra:readable' => $propertyMetadata->isReadable(),
496
            'hydra:writable' => $propertyMetadata->isWritable(),
497
        ];
498
499
        if (null !== $range = $this->getRange($propertyMetadata)) {
500
            $property['hydra:property']['range'] = $range;
501
        }
502
503
        if (null !== $description = $propertyMetadata->getDescription()) {
504
            $property['hydra:description'] = $description;
505
        }
506
507
        return $property;
508
    }
509
510
    /**
511
     * Computes the documentation.
512
     *
513
     * @param Documentation $object
514
     * @param array         $classes
515
     *
516
     * @return array
517
     */
518
    private function computeDoc(Documentation $object, array $classes): array
519
    {
520
        $doc = ['@context' => $this->getContext(), '@id' => $this->urlGenerator->generate('api_doc', ['_format' => self::FORMAT]), '@type' => 'hydra:ApiDocumentation'];
521
522
        if ('' !== $object->getTitle()) {
523
            $doc['hydra:title'] = $object->getTitle();
524
        }
525
526
        if ('' !== $object->getDescription()) {
527
            $doc['hydra:description'] = $object->getDescription();
528
        }
529
530
        $doc['hydra:entrypoint'] = $this->urlGenerator->generate('api_entrypoint');
531
        $doc['hydra:supportedClass'] = $classes;
532
533
        return $doc;
534
    }
535
536
    /**
537
     * Builds the JSON-LD context for the API documentation.
538
     *
539
     * @return array
540
     */
541
    private function getContext(): array
542
    {
543
        return [
544
            '@vocab' => $this->urlGenerator->generate('api_doc', ['_format' => self::FORMAT], UrlGeneratorInterface::ABS_URL).'#',
545
            'hydra' => ContextBuilderInterface::HYDRA_NS,
546
            'rdf' => ContextBuilderInterface::RDF_NS,
547
            'rdfs' => ContextBuilderInterface::RDFS_NS,
548
            'xmls' => ContextBuilderInterface::XML_NS,
549
            'owl' => ContextBuilderInterface::OWL_NS,
550
            'schema' => ContextBuilderInterface::SCHEMA_ORG_NS,
551
            'domain' => ['@id' => 'rdfs:domain', '@type' => '@id'],
552
            'range' => ['@id' => 'rdfs:range', '@type' => '@id'],
553
            'subClassOf' => ['@id' => 'rdfs:subClassOf', '@type' => '@id'],
554
            'expects' => ['@id' => 'hydra:expects', '@type' => '@id'],
555
            'returns' => ['@id' => 'hydra:returns', '@type' => '@id'],
556
        ];
557
    }
558
559
    /**
560
     * {@inheritdoc}
561
     */
562
    public function supportsNormalization($data, $format = null, array $context = [])
563
    {
564
        return self::FORMAT === $format && $data instanceof Documentation;
565
    }
566
}
567