JsonApiDocumentTopLevelNormalizer.php

Same filename in other branches
  1. 9 core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
  2. 8.9.x core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
  3. 11.x core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php

Namespace

Drupal\jsonapi\Normalizer

File

core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php

View source
<?php

namespace Drupal\jsonapi\Normalizer;

use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Uuid\Uuid;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\jsonapi\JsonApiResource\ErrorCollection;
use Drupal\jsonapi\JsonApiResource\OmittedData;
use Drupal\jsonapi\JsonApiSpec;
use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\jsonapi\ResourceType\ResourceType;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;

/**
 * Normalizes the top-level document according to the JSON:API specification.
 *
 * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
 *   class may change at any time and this will break any dependencies on it.
 *
 * @see https://www.drupal.org/project/drupal/issues/3032787
 * @see jsonapi.api.php
 *
 * @see \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel
 */
class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements DenormalizerInterface, NormalizerInterface {
    
    /**
     * The entity type manager.
     *
     * @var \Drupal\Core\Entity\EntityTypeManagerInterface
     */
    protected $entityTypeManager;
    
    /**
     * The JSON:API resource type repository.
     *
     * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
     */
    protected $resourceTypeRepository;
    
    /**
     * Constructs a JsonApiDocumentTopLevelNormalizer object.
     *
     * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
     *   The entity type manager.
     * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
     *   The JSON:API resource type repository.
     */
    public function __construct(EntityTypeManagerInterface $entity_type_manager, ResourceTypeRepositoryInterface $resource_type_repository) {
        $this->entityTypeManager = $entity_type_manager;
        $this->resourceTypeRepository = $resource_type_repository;
    }
    
    /**
     * {@inheritdoc}
     */
    public function denormalize($data, $class, $format = NULL, array $context = []) : mixed {
        $resource_type = $context['resource_type'];
        // Validate a few common errors in document formatting.
        static::validateRequestBody($data, $resource_type);
        $normalized = [];
        if (!empty($data['data']['attributes'])) {
            $normalized = $data['data']['attributes'];
        }
        if (!empty($data['data']['id'])) {
            $uuid_key = $this->entityTypeManager
                ->getDefinition($resource_type->getEntityTypeId())
                ->getKey('uuid');
            $normalized[$uuid_key] = $data['data']['id'];
        }
        if (!empty($data['data']['relationships'])) {
            // Turn all single object relationship data fields into an array of
            // objects.
            $relationships = array_map(function ($relationship) {
                if (isset($relationship['data']['type']) && isset($relationship['data']['id'])) {
                    return [
                        'data' => [
                            $relationship['data'],
                        ],
                    ];
                }
                else {
                    return $relationship;
                }
            }, $data['data']['relationships']);
            // Get an array of ids for every relationship.
            $relationships = array_map(function ($relationship) {
                if (empty($relationship['data'])) {
                    return [];
                }
                if (empty($relationship['data'][0]['id'])) {
                    throw new BadRequestHttpException("No ID specified for related resource");
                }
                $id_list = array_column($relationship['data'], 'id');
                if (empty($relationship['data'][0]['type'])) {
                    throw new BadRequestHttpException("No type specified for related resource");
                }
                if (!($resource_type = $this->resourceTypeRepository
                    ->getByTypeName($relationship['data'][0]['type']))) {
                    throw new BadRequestHttpException("Invalid type specified for related resource: '" . $relationship['data'][0]['type'] . "'");
                }
                $entity_type_id = $resource_type->getEntityTypeId();
                try {
                    $entity_storage = $this->entityTypeManager
                        ->getStorage($entity_type_id);
                } catch (PluginNotFoundException $e) {
                    throw new BadRequestHttpException("Invalid type specified for related resource: '" . $relationship['data'][0]['type'] . "'");
                }
                // In order to maintain the order ($delta) of the relationships, we need
                // to load the entities and create a mapping between id and uuid.
                $uuid_key = $this->entityTypeManager
                    ->getDefinition($entity_type_id)
                    ->getKey('uuid');
                $related_entities = array_values($entity_storage->loadByProperties([
                    $uuid_key => $id_list,
                ]));
                $map = [];
                foreach ($related_entities as $related_entity) {
                    $map[$related_entity->uuid()] = $related_entity->id();
                }
                // $id_list has the correct order of uuids. We stitch this together with
                // $map which contains loaded entities, and then bring in the correct
                // meta values from the relationship, whose deltas match with $id_list.
                $canonical_ids = [];
                foreach ($id_list as $delta => $uuid) {
                    if (!isset($map[$uuid])) {
                        // @see \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer::normalize()
                        if ($uuid === 'virtual') {
                            continue;
                        }
                        throw new NotFoundHttpException(sprintf('The resource identified by `%s:%s` (given as a relationship item) could not be found.', $relationship['data'][$delta]['type'], $uuid));
                    }
                    $reference_item = [
                        'target_id' => $map[$uuid],
                    ];
                    if (isset($relationship['data'][$delta]['meta'])) {
                        $reference_item += $relationship['data'][$delta]['meta'];
                    }
                    $canonical_ids[] = array_filter($reference_item, function ($key) {
                        return !str_starts_with($key, 'drupal_internal__');
                    }, ARRAY_FILTER_USE_KEY);
                }
                return array_filter($canonical_ids);
            }, $relationships);
            // Add the relationship ids.
            $normalized = array_merge($normalized, $relationships);
        }
        // Override deserialization target class with the one in the ResourceType.
        $class = $context['resource_type']->getDeserializationTargetClass();
        return $this->serializer
            ->denormalize($normalized, $class, $format, $context);
    }
    
    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = NULL, array $context = []) : array|string|int|float|bool|\ArrayObject|null {
        assert($object instanceof JsonApiDocumentTopLevel);
        $data = $object->getData();
        $document['jsonapi'] = CacheableNormalization::permanent([
            'version' => JsonApiSpec::SUPPORTED_SPECIFICATION_VERSION,
            'meta' => [
                'links' => [
                    'self' => [
                        'href' => JsonApiSpec::SUPPORTED_SPECIFICATION_PERMALINK,
                    ],
                ],
            ],
        ]);
        if ($data instanceof ErrorCollection) {
            $document['errors'] = $this->normalizeErrorDocument($object, $format, $context);
        }
        else {
            // Add data.
            $document['data'] = $this->serializer
                ->normalize($data, $format, $context);
            // Add includes.
            $document['included'] = $this->serializer
                ->normalize($object->getIncludes(), $format, $context)
                ->omitIfEmpty();
            // Add omissions and metadata.
            $normalized_omissions = $this->normalizeOmissionsLinks($object->getOmissions(), $format, $context);
            $meta = !$normalized_omissions instanceof CacheableOmission ? array_merge($object->getMeta(), [
                'omitted' => $normalized_omissions->getNormalization(),
            ]) : $object->getMeta();
            $document['meta'] = (new CacheableNormalization($normalized_omissions, $meta))->omitIfEmpty();
        }
        // Add document links.
        $document['links'] = $this->serializer
            ->normalize($object->getLinks(), $format, $context)
            ->omitIfEmpty();
        // Every JSON:API document contains absolute URLs.
        return CacheableNormalization::aggregate($document)->withCacheableDependency((new CacheableMetadata())->addCacheContexts([
            'url.site',
        ]));
    }
    
    /**
     * Normalizes an error collection.
     *
     * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $document
     *   The document to normalize.
     * @param string $format
     *   The normalization format.
     * @param array $context
     *   The normalization context.
     *
     * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
     *   The normalized document.
     *
     * @todo Refactor this to use CacheableNormalization::aggregate in https://www.drupal.org/project/drupal/issues/3036284.
     */
    protected function normalizeErrorDocument(JsonApiDocumentTopLevel $document, $format, array $context = []) {
        $normalized_values = array_map(function (HttpExceptionInterface $exception) use ($format, $context) {
            return $this->serializer
                ->normalize($exception, $format, $context);
        }, (array) $document->getData()
            ->getIterator());
        $cacheability = new CacheableMetadata();
        $errors = [];
        foreach ($normalized_values as $normalized_error) {
            $cacheability->addCacheableDependency($normalized_error);
            $errors = array_merge($errors, $normalized_error->getNormalization());
        }
        return new CacheableNormalization($cacheability, $errors);
    }
    
    /**
     * Normalizes omitted data into a set of omission links.
     *
     * @param \Drupal\jsonapi\JsonApiResource\OmittedData $omissions
     *   The omitted response data.
     * @param string $format
     *   The normalization format.
     * @param array $context
     *   The normalization context.
     *
     * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization|\Drupal\jsonapi\Normalizer\Value\CacheableOmission
     *   The normalized omissions.
     *
     * @todo Refactor this to use link collections in https://www.drupal.org/project/drupal/issues/3036279.
     */
    protected function normalizeOmissionsLinks(OmittedData $omissions, $format, array $context = []) {
        $normalized_omissions = array_map(function (HttpExceptionInterface $exception) use ($format, $context) {
            return $this->serializer
                ->normalize($exception, $format, $context);
        }, $omissions->toArray());
        $cacheability = CacheableMetadata::createFromObject(CacheableNormalization::aggregate($normalized_omissions));
        if (empty($normalized_omissions)) {
            return new CacheableOmission($cacheability);
        }
        $omission_links = [
            'detail' => 'Some resources have been omitted because of insufficient authorization.',
            'links' => [
                'help' => [
                    'href' => 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control',
                ],
            ],
        ];
        $link_hash_salt = Crypt::randomBytesBase64();
        foreach ($normalized_omissions as $omission) {
            $cacheability->addCacheableDependency($omission);
            // Add the errors to the pre-existing errors.
            foreach ($omission->getNormalization() as $error) {
                // JSON:API links cannot be arrays and the spec generally favors link
                // relation types as keys. 'item' is the right link relation type, but
                // we need multiple values. To do that, we generate a meaningless,
                // random value to use as a unique key. That value is a hash of a
                // random salt and the link href. This ensures that the key is non-
                // deterministic while letting use deduplicate the links by their
                // href. The salt is *not* used for any cryptographic reason.
                $link_key = 'item--' . static::getLinkHash($link_hash_salt, $error['links']['via']['href']);
                $omission_links['links'][$link_key] = [
                    'href' => $error['links']['via']['href'],
                    'meta' => [
                        'rel' => 'item',
                        'detail' => $error['detail'],
                    ],
                ];
            }
        }
        return new CacheableNormalization($cacheability, $omission_links);
    }
    
    /**
     * Performs minimal validation of the document.
     */
    protected static function validateRequestBody(array $document, ResourceType $resource_type) {
        // Ensure that the relationships key was not placed in the top level.
        if (isset($document['relationships']) && !empty($document['relationships'])) {
            throw new BadRequestHttpException("Found \"relationships\" within the document's top level. The \"relationships\" key must be within resource object.");
        }
        // Ensure that the resource object contains the "type" key.
        if (!isset($document['data']['type'])) {
            throw new BadRequestHttpException("Resource object must include a \"type\".");
        }
        // Ensure that the client provided ID is a valid UUID.
        if (isset($document['data']['id']) && !Uuid::isValid($document['data']['id'])) {
            throw new UnprocessableEntityHttpException('IDs should be properly generated and formatted UUIDs as described in RFC 4122.');
        }
        // Ensure that no relationship fields are being set via the attributes
        // resource object member.
        if (isset($document['data']['attributes'])) {
            $received_attribute_field_names = array_keys($document['data']['attributes']);
            $relationship_field_names = array_keys($resource_type->getRelatableResourceTypes());
            if ($relationship_fields_sent_as_attributes = array_intersect($received_attribute_field_names, $relationship_field_names)) {
                throw new UnprocessableEntityHttpException(sprintf("The following relationship fields were provided as attributes: [ %s ]", implode(', ', $relationship_fields_sent_as_attributes)));
            }
        }
    }
    
    /**
     * Hashes an omitted link.
     *
     * @param string $salt
     *   A hash salt.
     * @param string $link_href
     *   The omitted link.
     *
     * @return string
     *   A 7 character hash.
     */
    protected static function getLinkHash($salt, $link_href) {
        return substr(str_replace([
            '-',
            '_',
        ], '', Crypt::hashBase64($salt . $link_href)), 0, 7);
    }
    
    /**
     * {@inheritdoc}
     */
    public function hasCacheableSupportsMethod() : bool {
        @trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
        return TRUE;
    }
    
    /**
     * {@inheritdoc}
     */
    public function getSupportedTypes(?string $format) : array {
        return [
            JsonApiDocumentTopLevel::class => TRUE,
        ];
    }

}

Classes

Title Deprecated Summary
JsonApiDocumentTopLevelNormalizer Normalizes the top-level document according to the JSON:API specification.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.