IncludeResolver.php

Same filename in other branches
  1. 9 core/modules/jsonapi/src/IncludeResolver.php
  2. 8.9.x core/modules/jsonapi/src/IncludeResolver.php
  3. 10 core/modules/jsonapi/src/IncludeResolver.php

Namespace

Drupal\jsonapi

File

core/modules/jsonapi/src/IncludeResolver.php

View source
<?php

namespace Drupal\jsonapi;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
use Drupal\jsonapi\Access\EntityAccessChecker;
use Drupal\jsonapi\Context\FieldResolver;
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
use Drupal\jsonapi\JsonApiResource\Data;
use Drupal\jsonapi\JsonApiResource\IncludedData;
use Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject;
use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
use Drupal\jsonapi\ResourceType\ResourceType;

/**
 * Resolves included resources for an entity or collection of entities.
 *
 * @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
 */
class IncludeResolver {
    
    /**
     * The entity type manager.
     *
     * @var \Drupal\Core\Entity\EntityTypeManagerInterface
     */
    protected $entityTypeManager;
    
    /**
     * The JSON:API entity access checker.
     *
     * @var \Drupal\jsonapi\Access\EntityAccessChecker
     */
    protected $entityAccessChecker;
    
    /**
     * IncludeResolver constructor.
     */
    public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityAccessChecker $entity_access_checker) {
        $this->entityTypeManager = $entity_type_manager;
        $this->entityAccessChecker = $entity_access_checker;
    }
    
    /**
     * Resolves included resources.
     *
     * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\ResourceObjectData $data
     *   The resource(s) for which to resolve includes.
     * @param string $include_parameter
     *   The include query parameter to resolve.
     *
     * @return \Drupal\jsonapi\JsonApiResource\IncludedData
     *   An IncludedData object of resolved resources to be included.
     *
     * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
     *   Thrown if an included entity type doesn't exist.
     * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
     *   Thrown if a storage handler couldn't be loaded.
     */
    public function resolve($data, $include_parameter) {
        assert($data instanceof ResourceObject || $data instanceof ResourceObjectData);
        $data = $data instanceof ResourceObjectData ? $data : new ResourceObjectData([
            $data,
        ], 1);
        $include_tree = static::toIncludeTree($data, $include_parameter);
        return IncludedData::deduplicate($this->resolveIncludeTree($include_tree, $data));
    }
    
    /**
     * Receives a tree of include field names and resolves resources for it.
     *
     * This method takes a tree of relationship field names and JSON:API Data
     * object. For the top-level of the tree and for each entity in the
     * collection, it gets the target entity type and IDs for each relationship
     * field. The method then loads all of those targets and calls itself
     * recursively with the next level of the tree and those loaded resources.
     *
     * @param array $include_tree
     *   The include paths, represented as a tree.
     * @param \Drupal\jsonapi\JsonApiResource\Data $data
     *   The entity collection from which includes should be resolved.
     * @param \Drupal\jsonapi\JsonApiResource\Data|null $includes
     *   (Internal use only) Any prior resolved includes.
     *
     * @return \Drupal\jsonapi\JsonApiResource\Data
     *   A JSON:API Data of included items.
     *
     * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
     *   Thrown if an included entity type doesn't exist.
     * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
     *   Thrown if a storage handler couldn't be loaded.
     */
    protected function resolveIncludeTree(array $include_tree, Data $data, ?Data $includes = NULL) {
        $includes = is_null($includes) ? new IncludedData([]) : $includes;
        foreach ($include_tree as $field_name => $children) {
            $references = [];
            foreach ($data as $resource_object) {
                // Some objects in the collection may be LabelOnlyResourceObjects or
                // EntityAccessDeniedHttpException objects.
                assert($resource_object instanceof ResourceIdentifierInterface);
                $public_field_name = $resource_object->getResourceType()
                    ->getPublicName($field_name);
                if ($resource_object instanceof LabelOnlyResourceObject) {
                    $message = "The current user is not allowed to view this relationship.";
                    $exception = new EntityAccessDeniedHttpException($resource_object->getEntity(), AccessResult::forbidden("The user only has authorization for the 'view label' operation."), '', $message, $public_field_name);
                    $includes = IncludedData::merge($includes, new IncludedData([
                        $exception,
                    ]));
                    continue;
                }
                elseif (!$resource_object instanceof ResourceObject) {
                    continue;
                }
                // Not all entities in $entity_collection will be of the same bundle and
                // may not have all of the same fields. Therefore, calling
                // $resource_object->get($a_missing_field_name) will result in an
                // exception.
                if (!$resource_object->hasField($public_field_name)) {
                    continue;
                }
                $field_list = $resource_object->getField($public_field_name);
                // Config entities don't have real fields and can't have relationships.
                if (!$field_list instanceof FieldItemListInterface) {
                    continue;
                }
                $field_access = $field_list->access('view', NULL, TRUE);
                if (!$field_access->isAllowed()) {
                    $message = 'The current user is not allowed to view this relationship.';
                    $exception = new EntityAccessDeniedHttpException($field_list->getEntity(), $field_access, '', $message, $public_field_name);
                    $includes = IncludedData::merge($includes, new IncludedData([
                        $exception,
                    ]));
                    continue;
                }
                foreach ($field_list as $field_item) {
                    if (!$field_item->getDataDefinition()
                        ->getPropertyDefinition('entity') instanceof DataReferenceDefinitionInterface) {
                        continue;
                    }
                    if (!$field_item->entity instanceof EntityInterface) {
                        continue;
                    }
                    // Support entity reference fields that don't have the referenced
                    // target type stored in settings.
                    $references[$field_item->entity
                        ->getEntityTypeId()][] = $field_item->get($field_item::mainPropertyName())
                        ->getValue();
                }
            }
            foreach ($references as $target_type => $ids) {
                $entity_storage = $this->entityTypeManager
                    ->getStorage($target_type);
                $targeted_entities = $entity_storage->loadMultiple(array_unique($ids));
                $access_checked_entities = array_map(function (EntityInterface $entity) {
                    return $this->entityAccessChecker
                        ->getAccessCheckedResourceObject($entity);
                }, $targeted_entities);
                $targeted_collection = new IncludedData(array_filter($access_checked_entities, function (ResourceIdentifierInterface $resource_object) {
                    return !$resource_object->getResourceType()
                        ->isInternal();
                }));
                $includes = static::resolveIncludeTree($children, $targeted_collection, IncludedData::merge($includes, $targeted_collection));
            }
        }
        return $includes;
    }
    
    /**
     * Returns a tree of field names to include from an include parameter.
     *
     * @param \Drupal\jsonapi\JsonApiResource\ResourceObjectData $data
     *   The base resources for which includes should be resolved.
     * @param string $include_parameter
     *   The raw include parameter value.
     *
     * @return array
     *   A multi-dimensional array representing a tree of field names to be
     *   included. Array keys are the field names. Leaves are empty arrays.
     */
    protected static function toIncludeTree(ResourceObjectData $data, $include_parameter) {
        // $include_parameter: 'one.two.three, one.two.four'.
        $include_paths = array_map('trim', explode(',', $include_parameter));
        // $exploded_paths: [['one', 'two', 'three'], ['one', 'two', 'four']].
        $exploded_paths = array_map(function ($include_path) {
            return array_map('trim', explode('.', $include_path));
        }, $include_paths);
        $resolved_paths_per_resource_type = [];
        
        /** @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface $resource_object */
        foreach ($data as $resource_object) {
            $resource_type = $resource_object->getResourceType();
            $resource_type_name = $resource_type->getTypeName();
            if (isset($resolved_paths_per_resource_type[$resource_type_name])) {
                continue;
            }
            $resolved_paths_per_resource_type[$resource_type_name] = static::resolveInternalIncludePaths($resource_type, $exploded_paths);
        }
        $resolved_paths = array_reduce($resolved_paths_per_resource_type, 'array_merge', []);
        return static::buildTree($resolved_paths);
    }
    
    /**
     * Resolves an array of public field paths.
     *
     * @param \Drupal\jsonapi\ResourceType\ResourceType $base_resource_type
     *   The base resource type from which to resolve an internal include path.
     * @param array $paths
     *   An array of exploded include paths.
     *
     * @return array
     *   An array of all possible internal include paths derived from the given
     *   public include paths.
     *
     * @see self::buildTree
     */
    protected static function resolveInternalIncludePaths(ResourceType $base_resource_type, array $paths) {
        $internal_paths = array_map(function ($exploded_path) use ($base_resource_type) {
            if (empty($exploded_path)) {
                return [];
            }
            return FieldResolver::resolveInternalIncludePath($base_resource_type, $exploded_path);
        }, $paths);
        $flattened_paths = array_reduce($internal_paths, 'array_merge', []);
        return $flattened_paths;
    }
    
    /**
     * Takes an array of exploded paths and builds a tree of field names.
     *
     * Input example: [
     *   ['one', 'two', 'three'],
     *   ['one', 'two', 'four'],
     *   ['one', 'two', 'internal'],
     * ]
     *
     * Output example: [
     *   'one' => [
     *     'two' [
     *       'three' => [],
     *       'four' => [],
     *       'internal' => [],
     *     ],
     *   ],
     * ]
     *
     * @param array $paths
     *   An array of exploded include paths.
     *
     * @return array
     *   A multi-dimensional array representing a tree of field names to be
     *   included. Array keys are the field names. Leaves are empty arrays.
     */
    protected static function buildTree(array $paths) {
        $merged = [];
        foreach ($paths as $parts) {
            if (!($field_name = array_shift($parts))) {
                continue;
            }
            $previous = $merged[$field_name] ?? [];
            $merged[$field_name] = array_merge($previous, [
                $parts,
            ]);
        }
        return !empty($merged) ? array_map([
            static::class,
            __FUNCTION__,
        ], $merged) : $merged;
    }

}

Classes

Title Deprecated Summary
IncludeResolver Resolves included resources for an entity or collection of entities.

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