vendor/api-platform/core/src/Hydra/Serializer/DocumentationNormalizer.php line 51

  1. <?php
  2. /*
  3.  * This file is part of the API Platform project.
  4.  *
  5.  * (c) Kévin Dunglas <dunglas@gmail.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. declare(strict_types=1);
  11. namespace ApiPlatform\Hydra\Serializer;
  12. use ApiPlatform\Api\ResourceClassResolverInterface;
  13. use ApiPlatform\Api\UrlGeneratorInterface;
  14. use ApiPlatform\Documentation\Documentation;
  15. use ApiPlatform\JsonLd\ContextBuilderInterface;
  16. use ApiPlatform\Metadata\ApiProperty;
  17. use ApiPlatform\Metadata\ApiResource;
  18. use ApiPlatform\Metadata\CollectionOperationInterface;
  19. use ApiPlatform\Metadata\HttpOperation;
  20. use ApiPlatform\Metadata\Operation;
  21. use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
  22. use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
  23. use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
  24. use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
  25. use Symfony\Component\PropertyInfo\Type;
  26. use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
  27. use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
  28. use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
  29. use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
  30. /**
  31.  * Creates a machine readable Hydra API documentation.
  32.  *
  33.  * @author Kévin Dunglas <dunglas@gmail.com>
  34.  */
  35. final class DocumentationNormalizer implements NormalizerInterfaceCacheableSupportsMethodInterface
  36. {
  37.     public const FORMAT 'jsonld';
  38.     public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly UrlGeneratorInterface $urlGenerator, private readonly ?NameConverterInterface $nameConverter null)
  39.     {
  40.     }
  41.     /**
  42.      * {@inheritdoc}
  43.      */
  44.     public function normalize(mixed $objectstring $format null, array $context = []): array|string|int|float|bool|\ArrayObject|null
  45.     {
  46.         $classes = [];
  47.         $entrypointProperties = [];
  48.         foreach ($object->getResourceNameCollection() as $resourceClass) {
  49.             $resourceMetadataCollection $this->resourceMetadataFactory->create($resourceClass);
  50.             $resourceMetadata $resourceMetadataCollection[0];
  51.             $shortName $resourceMetadata->getShortName();
  52.             $prefixedShortName $resourceMetadata->getTypes()[0] ?? "#$shortName";
  53.             $this->populateEntrypointProperties($resourceMetadata$shortName$prefixedShortName$entrypointProperties$resourceMetadataCollection);
  54.             $classes[] = $this->getClass($resourceClass$resourceMetadata$shortName$prefixedShortName$context$resourceMetadataCollection);
  55.         }
  56.         return $this->computeDoc($object$this->getClasses($entrypointProperties$classes));
  57.     }
  58.     /**
  59.      * Populates entrypoint properties.
  60.      */
  61.     private function populateEntrypointProperties(ApiResource $resourceMetadatastring $shortNamestring $prefixedShortName, array &$entrypointProperties, ?ResourceMetadataCollection $resourceMetadataCollection null): void
  62.     {
  63.         $hydraCollectionOperations $this->getHydraOperations(true$resourceMetadataCollection);
  64.         if (empty($hydraCollectionOperations)) {
  65.             return;
  66.         }
  67.         $entrypointProperty = [
  68.             '@type' => 'hydra:SupportedProperty',
  69.             'hydra:property' => [
  70.                 '@id' => sprintf('#Entrypoint/%s'lcfirst($shortName)),
  71.                 '@type' => 'hydra:Link',
  72.                 'domain' => '#Entrypoint',
  73.                 'rdfs:label' => "The collection of $shortName resources",
  74.                 'rdfs:range' => [
  75.                     ['@id' => 'hydra:Collection'],
  76.                     [
  77.                         'owl:equivalentClass' => [
  78.                             'owl:onProperty' => ['@id' => 'hydra:member'],
  79.                             'owl:allValuesFrom' => ['@id' => $prefixedShortName],
  80.                         ],
  81.                     ],
  82.                 ],
  83.                 'hydra:supportedOperation' => $hydraCollectionOperations,
  84.             ],
  85.             'hydra:title' => "The collection of $shortName resources",
  86.             'hydra:readable' => true,
  87.             'hydra:writeable' => false,
  88.         ];
  89.         if ($resourceMetadata->getDeprecationReason()) {
  90.             $entrypointProperty['owl:deprecated'] = true;
  91.         }
  92.         $entrypointProperties[] = $entrypointProperty;
  93.     }
  94.     /**
  95.      * Gets a Hydra class.
  96.      */
  97.     private function getClass(string $resourceClassApiResource $resourceMetadatastring $shortNamestring $prefixedShortName, array $context, ?ResourceMetadataCollection $resourceMetadataCollection null): array
  98.     {
  99.         $description $resourceMetadata->getDescription();
  100.         $isDeprecated $resourceMetadata->getDeprecationReason();
  101.         $class = [
  102.             '@id' => $prefixedShortName,
  103.             '@type' => 'hydra:Class',
  104.             'rdfs:label' => $shortName,
  105.             'hydra:title' => $shortName,
  106.             'hydra:supportedProperty' => $this->getHydraProperties($resourceClass$resourceMetadata$shortName$prefixedShortName$context),
  107.             'hydra:supportedOperation' => $this->getHydraOperations(false$resourceMetadataCollection),
  108.         ];
  109.         if (null !== $description) {
  110.             $class['hydra:description'] = $description;
  111.         }
  112.         if ($isDeprecated) {
  113.             $class['owl:deprecated'] = true;
  114.         }
  115.         return $class;
  116.     }
  117.     /**
  118.      * Creates context for property metatata factories.
  119.      */
  120.     private function getPropertyMetadataFactoryContext(ApiResource $resourceMetadata): array
  121.     {
  122.         $normalizationGroups $resourceMetadata->getNormalizationContext()[AbstractNormalizer::GROUPS] ?? null;
  123.         $denormalizationGroups $resourceMetadata->getDenormalizationContext()[AbstractNormalizer::GROUPS] ?? null;
  124.         $propertyContext = [
  125.             'normalization_groups' => $normalizationGroups,
  126.             'denormalization_groups' => $denormalizationGroups,
  127.         ];
  128.         $propertyNameContext = [];
  129.         if ($normalizationGroups) {
  130.             $propertyNameContext['serializer_groups'] = $normalizationGroups;
  131.         }
  132.         if (!$denormalizationGroups) {
  133.             return [$propertyNameContext$propertyContext];
  134.         }
  135.         if (!isset($propertyNameContext['serializer_groups'])) {
  136.             $propertyNameContext['serializer_groups'] = $denormalizationGroups;
  137.             return [$propertyNameContext$propertyContext];
  138.         }
  139.         foreach ($denormalizationGroups as $group) {
  140.             $propertyNameContext['serializer_groups'][] = $group;
  141.         }
  142.         return [$propertyNameContext$propertyContext];
  143.     }
  144.     /**
  145.      * Gets Hydra properties.
  146.      */
  147.     private function getHydraProperties(string $resourceClassApiResource $resourceMetadatastring $shortNamestring $prefixedShortName, array $context): array
  148.     {
  149.         $classes = [];
  150.         $classes[$resourceClass] = true;
  151.         foreach ($resourceMetadata->getOperations() as $operation) {
  152.             /** @var Operation $operation */
  153.             if (!$operation instanceof CollectionOperationInterface) {
  154.                 continue;
  155.             }
  156.             $inputMetadata $operation->getInput();
  157.             if (null !== $inputClass $inputMetadata['class'] ?? null) {
  158.                 $classes[$inputClass] = true;
  159.             }
  160.             $outputMetadata $operation->getOutput();
  161.             if (null !== $outputClass $outputMetadata['class'] ?? null) {
  162.                 $classes[$outputClass] = true;
  163.             }
  164.         }
  165.         /** @var string[] $classes */
  166.         $classes array_keys($classes);
  167.         $properties = [];
  168.         [$propertyNameContext$propertyContext] = $this->getPropertyMetadataFactoryContext($resourceMetadata);
  169.         foreach ($classes as $class) {
  170.             foreach ($this->propertyNameCollectionFactory->create($class$propertyNameContext) as $propertyName) {
  171.                 $propertyMetadata $this->propertyMetadataFactory->create($class$propertyName$propertyContext);
  172.                 if (true === $propertyMetadata->isIdentifier() && false === $propertyMetadata->isWritable()) {
  173.                     continue;
  174.                 }
  175.                 if ($this->nameConverter) {
  176.                     $propertyName $this->nameConverter->normalize($propertyName$classself::FORMAT$context);
  177.                 }
  178.                 $properties[] = $this->getProperty($propertyMetadata$propertyName$prefixedShortName$shortName);
  179.             }
  180.         }
  181.         return $properties;
  182.     }
  183.     /**
  184.      * Gets Hydra operations.
  185.      */
  186.     private function getHydraOperations(bool $collection, ?ResourceMetadataCollection $resourceMetadataCollection null): array
  187.     {
  188.         $hydraOperations = [];
  189.         foreach ($resourceMetadataCollection as $resourceMetadata) {
  190.             foreach ($resourceMetadata->getOperations() as $operation) {
  191.                 if ((HttpOperation::METHOD_POST === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) {
  192.                     continue;
  193.                 }
  194.                 $hydraOperations[] = $this->getHydraOperation($operation$operation->getTypes()[0] ?? "#{$operation->getShortName()}");
  195.             }
  196.         }
  197.         return $hydraOperations;
  198.     }
  199.     /**
  200.      * Gets and populates if applicable a Hydra operation.
  201.      */
  202.     private function getHydraOperation(HttpOperation $operationstring $prefixedShortName): array
  203.     {
  204.         $method $operation->getMethod() ?: HttpOperation::METHOD_GET;
  205.         $hydraOperation $operation->getHydraContext() ?? [];
  206.         if ($operation->getDeprecationReason()) {
  207.             $hydraOperation['owl:deprecated'] = true;
  208.         }
  209.         $shortName $operation->getShortName();
  210.         $inputMetadata $operation->getInput() ?? [];
  211.         $outputMetadata $operation->getOutput() ?? [];
  212.         $inputClass \array_key_exists('class'$inputMetadata) ? $inputMetadata['class'] : false;
  213.         $outputClass \array_key_exists('class'$outputMetadata) ? $outputMetadata['class'] : false;
  214.         if ('GET' === $method && $operation instanceof CollectionOperationInterface) {
  215.             $hydraOperation += [
  216.                 '@type' => ['hydra:Operation''schema:FindAction'],
  217.                 'hydra:title' => "Retrieves the collection of $shortName resources.",
  218.                 'returns' => null === $outputClass 'owl:Nothing' 'hydra:Collection',
  219.             ];
  220.         } elseif ('GET' === $method) {
  221.             $hydraOperation += [
  222.                 '@type' => ['hydra:Operation''schema:FindAction'],
  223.                 'hydra:title' => "Retrieves a $shortName resource.",
  224.                 'returns' => null === $outputClass 'owl:Nothing' $prefixedShortName,
  225.             ];
  226.         } elseif ('PATCH' === $method) {
  227.             $hydraOperation += [
  228.                 '@type' => 'hydra:Operation',
  229.                 'hydra:title' => "Updates the $shortName resource.",
  230.                 'returns' => null === $outputClass 'owl:Nothing' $prefixedShortName,
  231.                 'expects' => null === $inputClass 'owl:Nothing' $prefixedShortName,
  232.             ];
  233.         } elseif ('POST' === $method) {
  234.             $hydraOperation += [
  235.                 '@type' => ['hydra:Operation''schema:CreateAction'],
  236.                 'hydra:title' => "Creates a $shortName resource.",
  237.                 'returns' => null === $outputClass 'owl:Nothing' $prefixedShortName,
  238.                 'expects' => null === $inputClass 'owl:Nothing' $prefixedShortName,
  239.             ];
  240.         } elseif ('PUT' === $method) {
  241.             $hydraOperation += [
  242.                 '@type' => ['hydra:Operation''schema:ReplaceAction'],
  243.                 'hydra:title' => "Replaces the $shortName resource.",
  244.                 'returns' => null === $outputClass 'owl:Nothing' $prefixedShortName,
  245.                 'expects' => null === $inputClass 'owl:Nothing' $prefixedShortName,
  246.             ];
  247.         } elseif ('DELETE' === $method) {
  248.             $hydraOperation += [
  249.                 '@type' => ['hydra:Operation''schema:DeleteAction'],
  250.                 'hydra:title' => "Deletes the $shortName resource.",
  251.                 'returns' => 'owl:Nothing',
  252.             ];
  253.         }
  254.         $hydraOperation['hydra:method'] ?? $hydraOperation['hydra:method'] = $method;
  255.         if (!isset($hydraOperation['rdfs:label']) && isset($hydraOperation['hydra:title'])) {
  256.             $hydraOperation['rdfs:label'] = $hydraOperation['hydra:title'];
  257.         }
  258.         ksort($hydraOperation);
  259.         return $hydraOperation;
  260.     }
  261.     /**
  262.      * Gets the range of the property.
  263.      */
  264.     private function getRange(ApiProperty $propertyMetadata): ?string
  265.     {
  266.         $jsonldContext $propertyMetadata->getJsonldContext();
  267.         if (isset($jsonldContext['@type'])) {
  268.             return $jsonldContext['@type'];
  269.         }
  270.         // TODO: 3.0 support multiple types, default value of types will be [] instead of null
  271.         $type $propertyMetadata->getBuiltinTypes()[0] ?? null;
  272.         if (null === $type) {
  273.             return null;
  274.         }
  275.         if ($type->isCollection() && null !== $collectionType $type->getCollectionValueTypes()[0] ?? null) {
  276.             $type $collectionType;
  277.         }
  278.         switch ($type->getBuiltinType()) {
  279.             case Type::BUILTIN_TYPE_STRING:
  280.                 return 'xmls:string';
  281.             case Type::BUILTIN_TYPE_INT:
  282.                 return 'xmls:integer';
  283.             case Type::BUILTIN_TYPE_FLOAT:
  284.                 return 'xmls:decimal';
  285.             case Type::BUILTIN_TYPE_BOOL:
  286.                 return 'xmls:boolean';
  287.             case Type::BUILTIN_TYPE_OBJECT:
  288.                 if (null === $className $type->getClassName()) {
  289.                     return null;
  290.                 }
  291.                 if (is_a($className\DateTimeInterface::class, true)) {
  292.                     return 'xmls:dateTime';
  293.                 }
  294.                 if ($this->resourceClassResolver->isResourceClass($className)) {
  295.                     $resourceMetadata $this->resourceMetadataFactory->create($className);
  296.                     $operation $resourceMetadata->getOperation();
  297.                     if (!$operation instanceof HttpOperation) {
  298.                         return "#{$operation->getShortName()}";
  299.                     }
  300.                     return $operation->getTypes()[0] ?? "#{$operation->getShortName()}";
  301.                 }
  302.         }
  303.         return null;
  304.     }
  305.     /**
  306.      * Builds the classes array.
  307.      */
  308.     private function getClasses(array $entrypointProperties, array $classes): array
  309.     {
  310.         $classes[] = [
  311.             '@id' => '#Entrypoint',
  312.             '@type' => 'hydra:Class',
  313.             'hydra:title' => 'The API entrypoint',
  314.             'hydra:supportedProperty' => $entrypointProperties,
  315.             'hydra:supportedOperation' => [
  316.                 '@type' => 'hydra:Operation',
  317.                 'hydra:method' => 'GET',
  318.                 'rdfs:label' => 'The API entrypoint.',
  319.                 'returns' => '#EntryPoint',
  320.             ],
  321.         ];
  322.         // Constraint violation
  323.         $classes[] = [
  324.             '@id' => '#ConstraintViolation',
  325.             '@type' => 'hydra:Class',
  326.             'hydra:title' => 'A constraint violation',
  327.             'hydra:supportedProperty' => [
  328.                 [
  329.                     '@type' => 'hydra:SupportedProperty',
  330.                     'hydra:property' => [
  331.                         '@id' => '#ConstraintViolation/propertyPath',
  332.                         '@type' => 'rdf:Property',
  333.                         'rdfs:label' => 'propertyPath',
  334.                         'domain' => '#ConstraintViolation',
  335.                         'range' => 'xmls:string',
  336.                     ],
  337.                     'hydra:title' => 'propertyPath',
  338.                     'hydra:description' => 'The property path of the violation',
  339.                     'hydra:readable' => true,
  340.                     'hydra:writeable' => false,
  341.                 ],
  342.                 [
  343.                     '@type' => 'hydra:SupportedProperty',
  344.                     'hydra:property' => [
  345.                         '@id' => '#ConstraintViolation/message',
  346.                         '@type' => 'rdf:Property',
  347.                         'rdfs:label' => 'message',
  348.                         'domain' => '#ConstraintViolation',
  349.                         'range' => 'xmls:string',
  350.                     ],
  351.                     'hydra:title' => 'message',
  352.                     'hydra:description' => 'The message associated with the violation',
  353.                     'hydra:readable' => true,
  354.                     'hydra:writeable' => false,
  355.                 ],
  356.             ],
  357.         ];
  358.         // Constraint violation list
  359.         $classes[] = [
  360.             '@id' => '#ConstraintViolationList',
  361.             '@type' => 'hydra:Class',
  362.             'subClassOf' => 'hydra:Error',
  363.             'hydra:title' => 'A constraint violation list',
  364.             'hydra:supportedProperty' => [
  365.                 [
  366.                     '@type' => 'hydra:SupportedProperty',
  367.                     'hydra:property' => [
  368.                         '@id' => '#ConstraintViolationList/violations',
  369.                         '@type' => 'rdf:Property',
  370.                         'rdfs:label' => 'violations',
  371.                         'domain' => '#ConstraintViolationList',
  372.                         'range' => '#ConstraintViolation',
  373.                     ],
  374.                     'hydra:title' => 'violations',
  375.                     'hydra:description' => 'The violations',
  376.                     'hydra:readable' => true,
  377.                     'hydra:writeable' => false,
  378.                 ],
  379.             ],
  380.         ];
  381.         return $classes;
  382.     }
  383.     /**
  384.      * Gets a property definition.
  385.      */
  386.     private function getProperty(ApiProperty $propertyMetadatastring $propertyNamestring $prefixedShortNamestring $shortName): array
  387.     {
  388.         if ($iri $propertyMetadata->getIris()) {
  389.             $iri === (is_countable($iri) ? \count($iri) : 0) ? $iri[0] : $iri;
  390.         }
  391.         if (!isset($iri)) {
  392.             $iri "#$shortName/$propertyName";
  393.         }
  394.         $propertyData = [
  395.             '@id' => $iri,
  396.             '@type' => false === $propertyMetadata->isReadableLink() ? 'hydra:Link' 'rdf:Property',
  397.             'rdfs:label' => $propertyName,
  398.             'domain' => $prefixedShortName,
  399.         ];
  400.         // TODO: 3.0 support multiple types, default value of types will be [] instead of null
  401.         $type $propertyMetadata->getBuiltinTypes()[0] ?? null;
  402.         if (null !== $type && !$type->isCollection() && (null !== $className $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) {
  403.             $propertyData['owl:maxCardinality'] = 1;
  404.         }
  405.         $property = [
  406.             '@type' => 'hydra:SupportedProperty',
  407.             'hydra:property' => $propertyData,
  408.             'hydra:title' => $propertyName,
  409.             'hydra:required' => $propertyMetadata->isRequired(),
  410.             'hydra:readable' => $propertyMetadata->isReadable(),
  411.             'hydra:writeable' => $propertyMetadata->isWritable() || $propertyMetadata->isInitializable(),
  412.         ];
  413.         if (null !== $range $this->getRange($propertyMetadata)) {
  414.             $property['hydra:property']['range'] = $range;
  415.         }
  416.         if (null !== $description $propertyMetadata->getDescription()) {
  417.             $property['hydra:description'] = $description;
  418.         }
  419.         if ($deprecationReason $propertyMetadata->getDeprecationReason()) {
  420.             $property['owl:deprecated'] = true;
  421.         }
  422.         return $property;
  423.     }
  424.     /**
  425.      * Computes the documentation.
  426.      */
  427.     private function computeDoc(Documentation $object, array $classes): array
  428.     {
  429.         $doc = ['@context' => $this->getContext(), '@id' => $this->urlGenerator->generate('api_doc', ['_format' => self::FORMAT]), '@type' => 'hydra:ApiDocumentation'];
  430.         if ('' !== $object->getTitle()) {
  431.             $doc['hydra:title'] = $object->getTitle();
  432.         }
  433.         if ('' !== $object->getDescription()) {
  434.             $doc['hydra:description'] = $object->getDescription();
  435.         }
  436.         $doc['hydra:entrypoint'] = $this->urlGenerator->generate('api_entrypoint');
  437.         $doc['hydra:supportedClass'] = $classes;
  438.         return $doc;
  439.     }
  440.     /**
  441.      * Builds the JSON-LD context for the API documentation.
  442.      */
  443.     private function getContext(): array
  444.     {
  445.         return [
  446.             '@vocab' => $this->urlGenerator->generate('api_doc', ['_format' => self::FORMAT], UrlGeneratorInterface::ABS_URL).'#',
  447.             'hydra' => ContextBuilderInterface::HYDRA_NS,
  448.             'rdf' => ContextBuilderInterface::RDF_NS,
  449.             'rdfs' => ContextBuilderInterface::RDFS_NS,
  450.             'xmls' => ContextBuilderInterface::XML_NS,
  451.             'owl' => ContextBuilderInterface::OWL_NS,
  452.             'schema' => ContextBuilderInterface::SCHEMA_ORG_NS,
  453.             'domain' => ['@id' => 'rdfs:domain''@type' => '@id'],
  454.             'range' => ['@id' => 'rdfs:range''@type' => '@id'],
  455.             'subClassOf' => ['@id' => 'rdfs:subClassOf''@type' => '@id'],
  456.             'expects' => ['@id' => 'hydra:expects''@type' => '@id'],
  457.             'returns' => ['@id' => 'hydra:returns''@type' => '@id'],
  458.         ];
  459.     }
  460.     /**
  461.      * {@inheritdoc}
  462.      */
  463.     public function supportsNormalization(mixed $datastring $format null, array $context = []): bool
  464.     {
  465.         return self::FORMAT === $format && $data instanceof Documentation;
  466.     }
  467.     public function hasCacheableSupportsMethod(): bool
  468.     {
  469.         return true;
  470.     }
  471. }