Chris@18: 0); Chris@18: $merged_document = []; Chris@18: $merged_cacheability = new CacheableMetadata(); Chris@18: foreach ($responses as $response) { Chris@18: $response_document = $response->getResponseData(); Chris@18: // If any of the response documents had top-level errors, we should later Chris@18: // expect the merged document to have all errors as omitted links under Chris@18: // the 'meta.omitted' member. Chris@18: if (!empty($response_document['errors'])) { Chris@18: static::addOmittedObject($merged_document, static::errorsToOmittedObject($response_document['errors'])); Chris@18: } Chris@18: if (!empty($response_document['meta']['omitted'])) { Chris@18: static::addOmittedObject($merged_document, $response_document['meta']['omitted']); Chris@18: } Chris@18: elseif (isset($response_document['data'])) { Chris@18: $response_data = $response_document['data']; Chris@18: if (!isset($merged_document['data'])) { Chris@18: $merged_document['data'] = static::isResourceIdentifier($response_data) && $is_multiple Chris@18: ? [$response_data] Chris@18: : $response_data; Chris@18: } Chris@18: else { Chris@18: $response_resources = static::isResourceIdentifier($response_data) Chris@18: ? [$response_data] Chris@18: : $response_data; Chris@18: foreach ($response_resources as $response_resource) { Chris@18: $merged_document['data'][] = $response_resource; Chris@18: } Chris@18: } Chris@18: } Chris@18: $merged_cacheability->addCacheableDependency($response->getCacheableMetadata()); Chris@18: } Chris@18: $merged_document['jsonapi'] = [ Chris@18: 'meta' => [ Chris@18: 'links' => [ Chris@18: 'self' => ['href' => 'http://jsonapi.org/format/1.0/'], Chris@18: ], Chris@18: ], Chris@18: 'version' => '1.0', Chris@18: ]; Chris@18: // Until we can reasonably know what caused an error, we shouldn't include Chris@18: // 'self' links in error documents. For example, a 404 shouldn't have a Chris@18: // 'self' link because HATEOAS links shouldn't point to resources which do Chris@18: // not exist. Chris@18: if (isset($merged_document['errors'])) { Chris@18: unset($merged_document['links']); Chris@18: } Chris@18: else { Chris@18: if (!isset($merged_document['data'])) { Chris@18: $merged_document['data'] = $is_multiple ? [] : NULL; Chris@18: } Chris@18: $merged_document['links'] = [ Chris@18: 'self' => [ Chris@18: 'href' => $self_link, Chris@18: ], Chris@18: ]; Chris@18: } Chris@18: // All collections should be 200, without regard for the status of the Chris@18: // individual resources in those collections, which means any '4xx-response' Chris@18: // cache tags on the individual responses should also be omitted. Chris@18: $merged_cacheability->setCacheTags(array_diff($merged_cacheability->getCacheTags(), ['4xx-response'])); Chris@18: return (new ResourceResponse($merged_document, 200))->addCacheableDependency($merged_cacheability); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets an array of expected ResourceResponses for the given include paths. Chris@18: * Chris@18: * @param array $include_paths Chris@18: * The list of relationship include paths for which to get expected data. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The expected ResourceResponse. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getExpectedIncludedResourceResponse(array $include_paths, array $request_options) { Chris@18: $resource_type = $this->resourceType; Chris@18: $resource_data = array_reduce($include_paths, function ($data, $path) use ($request_options, $resource_type) { Chris@18: $field_names = explode('.', $path); Chris@18: /* @var \Drupal\Core\Entity\EntityInterface $entity */ Chris@18: $entity = $this->entity; Chris@18: $collected_responses = []; Chris@18: foreach ($field_names as $public_field_name) { Chris@18: $resource_type = $this->container->get('jsonapi.resource_type.repository')->get($entity->getEntityTypeId(), $entity->bundle()); Chris@18: $field_name = $resource_type->getInternalName($public_field_name); Chris@18: $field_access = static::entityFieldAccess($entity, $field_name, 'view', $this->account); Chris@18: if (!$field_access->isAllowed()) { Chris@18: if (!$entity->access('view') && $entity->access('view label') && $field_access instanceof AccessResultReasonInterface && empty($field_access->getReason())) { Chris@18: $field_access->setReason("The user only has authorization for the 'view label' operation."); Chris@18: } Chris@18: $via_link = Url::fromRoute( Chris@18: sprintf('jsonapi.%s.%s.related', $entity->getEntityTypeId() . '--' . $entity->bundle(), $public_field_name), Chris@18: ['entity' => $entity->uuid()] Chris@18: ); Chris@18: $collected_responses[] = static::getAccessDeniedResponse($entity, $field_access, $via_link, $field_name, 'The current user is not allowed to view this relationship.', $field_name); Chris@18: break; Chris@18: } Chris@18: if ($target_entity = $entity->{$field_name}->entity) { Chris@18: $target_access = static::entityAccess($target_entity, 'view', $this->account); Chris@18: if (!$target_access->isAllowed()) { Chris@18: $target_access = static::entityAccess($target_entity, 'view label', $this->account)->addCacheableDependency($target_access); Chris@18: } Chris@18: if (!$target_access->isAllowed()) { Chris@18: $resource_identifier = static::toResourceIdentifier($target_entity); Chris@18: if (!static::collectionHasResourceIdentifier($resource_identifier, $data['already_checked'])) { Chris@18: $data['already_checked'][] = $resource_identifier; Chris@18: $via_link = Url::fromRoute( Chris@18: sprintf('jsonapi.%s.individual', $resource_identifier['type']), Chris@18: ['entity' => $resource_identifier['id']] Chris@18: ); Chris@18: $collected_responses[] = static::getAccessDeniedResponse($entity, $target_access, $via_link, NULL, NULL, '/data'); Chris@18: } Chris@18: break; Chris@18: } Chris@18: } Chris@18: $psr_responses = $this->getResponses([static::getRelatedLink(static::toResourceIdentifier($entity), $public_field_name)], $request_options); Chris@18: $collected_responses[] = static::toCollectionResourceResponse(static::toResourceResponses($psr_responses), NULL, TRUE); Chris@18: $entity = $entity->{$field_name}->entity; Chris@18: } Chris@18: if (!empty($collected_responses)) { Chris@18: $data['responses'][$path] = static::toCollectionResourceResponse($collected_responses, NULL, TRUE); Chris@18: } Chris@18: return $data; Chris@18: }, ['responses' => [], 'already_checked' => []]); Chris@18: Chris@18: $individual_document = $this->getExpectedDocument(); Chris@18: Chris@18: $expected_base_url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute(); Chris@18: $include_url = clone $expected_base_url; Chris@18: $query = ['include' => implode(',', $include_paths)]; Chris@18: $include_url->setOption('query', $query); Chris@18: $individual_document['links']['self']['href'] = $include_url->toString(); Chris@18: Chris@18: // The test entity reference field should always be present. Chris@18: if (!isset($individual_document['data']['relationships']['field_jsonapi_test_entity_ref'])) { Chris@18: if (static::$resourceTypeIsVersionable) { Chris@18: assert($this->entity instanceof RevisionableInterface); Chris@18: $version_identifier = 'id:' . $this->entity->getRevisionId(); Chris@18: $version_query_string = '?resourceVersion=' . urlencode($version_identifier); Chris@18: } Chris@18: else { Chris@18: $version_query_string = ''; Chris@18: } Chris@18: $individual_document['data']['relationships']['field_jsonapi_test_entity_ref'] = [ Chris@18: 'data' => [], Chris@18: 'links' => [ Chris@18: 'related' => [ Chris@18: 'href' => $expected_base_url->toString() . '/field_jsonapi_test_entity_ref' . $version_query_string, Chris@18: ], Chris@18: 'self' => [ Chris@18: 'href' => $expected_base_url->toString() . '/relationships/field_jsonapi_test_entity_ref' . $version_query_string, Chris@18: ], Chris@18: ], Chris@18: ]; Chris@18: } Chris@18: Chris@18: $basic_cacheability = (new CacheableMetadata()) Chris@18: ->addCacheTags($this->getExpectedCacheTags()) Chris@18: ->addCacheContexts($this->getExpectedCacheContexts()); Chris@18: return static::decorateExpectedResponseForIncludedFields(ResourceResponse::create($individual_document), $resource_data['responses']) Chris@18: ->addCacheableDependency($basic_cacheability); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Maps an array of PSR responses to JSON:API ResourceResponses. Chris@18: * Chris@18: * @param \Psr\Http\Message\ResponseInterface[] $responses Chris@18: * The PSR responses to be mapped. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse[] Chris@18: * The ResourceResponses. Chris@18: */ Chris@18: protected static function toResourceResponses(array $responses) { Chris@18: return array_map([self::class, 'toResourceResponse'], $responses); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Maps a response object to a JSON:API ResourceResponse. Chris@18: * Chris@18: * This helper can be used to ease comparing, recording and merging Chris@18: * cacheable responses and to have easier access to the JSON:API document as Chris@18: * an array instead of a string. Chris@18: * Chris@18: * @param \Psr\Http\Message\ResponseInterface $response Chris@18: * A PSR response to be mapped. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The ResourceResponse. Chris@18: */ Chris@18: protected static function toResourceResponse(ResponseInterface $response) { Chris@18: $cacheability = new CacheableMetadata(); Chris@18: if ($cache_tags = $response->getHeader('X-Drupal-Cache-Tags')) { Chris@18: $cacheability->addCacheTags(explode(' ', $cache_tags[0])); Chris@18: } Chris@18: if (!empty($response->getHeaderLine('X-Drupal-Cache-Contexts'))) { Chris@18: $cacheability->addCacheContexts(explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0])); Chris@18: } Chris@18: if ($dynamic_cache = $response->getHeader('X-Drupal-Dynamic-Cache')) { Chris@18: $cacheability->setCacheMaxAge(($dynamic_cache[0] === 'UNCACHEABLE' && $response->getStatusCode() < 400) ? 0 : Cache::PERMANENT); Chris@18: } Chris@18: $related_document = Json::decode($response->getBody()); Chris@18: $resource_response = new ResourceResponse($related_document, $response->getStatusCode()); Chris@18: return $resource_response->addCacheableDependency($cacheability); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Maps an entity to a resource identifier. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity to map to a resource identifier. Chris@18: * Chris@18: * @return array Chris@18: * A resource identifier for the given entity. Chris@18: */ Chris@18: protected static function toResourceIdentifier(EntityInterface $entity) { Chris@18: return [ Chris@18: 'type' => $entity->getEntityTypeId() . '--' . $entity->bundle(), Chris@18: 'id' => $entity->uuid(), Chris@18: ]; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Checks if a given array is a resource identifier. Chris@18: * Chris@18: * @param array $data Chris@18: * An array to check. Chris@18: * Chris@18: * @return bool Chris@18: * TRUE if the array has a type and ID, FALSE otherwise. Chris@18: */ Chris@18: protected static function isResourceIdentifier(array $data) { Chris@18: return array_key_exists('type', $data) && array_key_exists('id', $data); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Sorts a collection of resources or resource identifiers. Chris@18: * Chris@18: * This is useful for asserting collections or resources where order cannot Chris@18: * be known in advance. Chris@18: * Chris@18: * @param array $resources Chris@18: * The resource or resource identifier. Chris@18: */ Chris@18: protected static function sortResourceCollection(array &$resources) { Chris@18: usort($resources, function ($a, $b) { Chris@18: return strcmp("{$a['type']}:{$a['id']}", "{$b['type']}:{$b['id']}"); Chris@18: }); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Determines if a given resource exists in a list of resources. Chris@18: * Chris@18: * @param array $needle Chris@18: * The resource or resource identifier. Chris@18: * @param array $haystack Chris@18: * The list of resources or resource identifiers to search. Chris@18: * Chris@18: * @return bool Chris@18: * TRUE if the needle exists is present in the haystack, FALSE otherwise. Chris@18: */ Chris@18: protected static function collectionHasResourceIdentifier(array $needle, array $haystack) { Chris@18: foreach ($haystack as $resource) { Chris@18: if ($resource['type'] == $needle['type'] && $resource['id'] == $needle['id']) { Chris@18: return TRUE; Chris@18: } Chris@18: } Chris@18: return FALSE; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Turns a list of relationship field names into an array of link paths. Chris@18: * Chris@18: * @param array $relationship_field_names Chris@18: * The relationships field names for which to build link paths. Chris@18: * @param string $type Chris@18: * The type of link to get. Either 'relationship' or 'related'. Chris@18: * Chris@18: * @return array Chris@18: * An array of link paths, keyed by relationship field name. Chris@18: */ Chris@18: protected static function getLinkPaths(array $relationship_field_names, $type) { Chris@18: assert($type === 'relationship' || $type === 'related'); Chris@18: return array_reduce($relationship_field_names, function ($link_paths, $relationship_field_name) use ($type) { Chris@18: $tail = $type === 'relationship' ? 'self' : $type; Chris@18: $link_paths[$relationship_field_name] = "data.relationships.$relationship_field_name.links.$tail.href"; Chris@18: return $link_paths; Chris@18: }, []); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Extracts links from a document using a list of relationship field names. Chris@18: * Chris@18: * @param array $link_paths Chris@18: * A list of paths to link values keyed by a name. Chris@18: * @param array $document Chris@18: * A JSON:API document. Chris@18: * Chris@18: * @return array Chris@18: * The extracted links, keyed by the original associated key name. Chris@18: */ Chris@18: protected static function extractLinks(array $link_paths, array $document) { Chris@18: return array_map(function ($link_path) use ($document) { Chris@18: $link = array_reduce( Chris@18: explode('.', $link_path), Chris@18: 'array_column', Chris@18: [$document] Chris@18: ); Chris@18: return ($link) ? reset($link) : NULL; Chris@18: }, $link_paths); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Creates individual resource links for a list of resource identifiers. Chris@18: * Chris@18: * @param array $resource_identifiers Chris@18: * A list of resource identifiers for which to create links. Chris@18: * Chris@18: * @return string[] Chris@18: * The resource links. Chris@18: */ Chris@18: protected static function getResourceLinks(array $resource_identifiers) { Chris@18: return array_map([static::class, 'getResourceLink'], $resource_identifiers); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Creates an individual resource link for a given resource identifier. Chris@18: * Chris@18: * @param array $resource_identifier Chris@18: * A resource identifier for which to create a link. Chris@18: * Chris@18: * @return string Chris@18: * The resource link. Chris@18: */ Chris@18: protected static function getResourceLink(array $resource_identifier) { Chris@18: assert(static::isResourceIdentifier($resource_identifier)); Chris@18: $resource_type = $resource_identifier['type']; Chris@18: $resource_id = $resource_identifier['id']; Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.individual', $resource_type), ['entity' => $resource_id]); Chris@18: return $url->setAbsolute()->toString(); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Creates a relationship link for a given resource identifier and field. Chris@18: * Chris@18: * @param array $resource_identifier Chris@18: * A resource identifier for which to create a link. Chris@18: * @param string $relationship_field_name Chris@18: * The relationship field for which to create a link. Chris@18: * Chris@18: * @return string Chris@18: * The relationship link. Chris@18: */ Chris@18: protected static function getRelationshipLink(array $resource_identifier, $relationship_field_name) { Chris@18: return static::getResourceLink($resource_identifier) . "/relationships/$relationship_field_name"; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Creates a related resource link for a given resource identifier and field. Chris@18: * Chris@18: * @param array $resource_identifier Chris@18: * A resource identifier for which to create a link. Chris@18: * @param string $relationship_field_name Chris@18: * The relationship field for which to create a link. Chris@18: * Chris@18: * @return string Chris@18: * The related resource link. Chris@18: */ Chris@18: protected static function getRelatedLink(array $resource_identifier, $relationship_field_name) { Chris@18: return static::getResourceLink($resource_identifier) . "/$relationship_field_name"; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets an array of related responses for the given field names. Chris@18: * Chris@18: * @param array $relationship_field_names Chris@18: * The list of relationship field names for which to get responses. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * @param \Drupal\Core\Entity\EntityInterface|null $entity Chris@18: * (optional) The entity for which to get expected related responses. Chris@18: * Chris@18: * @return array Chris@18: * The related responses, keyed by relationship field names. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) { Chris@18: $entity = $entity ?: $this->entity; Chris@18: $links = array_map(function ($relationship_field_name) use ($entity) { Chris@18: return static::getRelatedLink(static::toResourceIdentifier($entity), $relationship_field_name); Chris@18: }, array_combine($relationship_field_names, $relationship_field_names)); Chris@18: return $this->getResponses($links, $request_options); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets an array of relationship responses for the given field names. Chris@18: * Chris@18: * @param array $relationship_field_names Chris@18: * The list of relationship field names for which to get responses. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @return array Chris@18: * The relationship responses, keyed by relationship field names. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getRelationshipResponses(array $relationship_field_names, array $request_options) { Chris@18: $links = array_map(function ($relationship_field_name) { Chris@18: return static::getRelationshipLink(static::toResourceIdentifier($this->entity), $relationship_field_name); Chris@18: }, array_combine($relationship_field_names, $relationship_field_names)); Chris@18: return $this->getResponses($links, $request_options); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets responses from an array of links. Chris@18: * Chris@18: * @param array $links Chris@18: * A keyed array of links. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @return array Chris@18: * The fetched array of responses, keys are preserved. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getResponses(array $links, array $request_options) { Chris@18: return array_reduce(array_keys($links), function ($related_responses, $key) use ($links, $request_options) { Chris@18: $related_responses[$key] = $this->request('GET', Url::fromUri($links[$key]), $request_options); Chris@18: return $related_responses; Chris@18: }, []); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets a generic forbidden response. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity for which to generate the forbidden response. Chris@18: * @param \Drupal\Core\Access\AccessResultInterface $access Chris@18: * The denied AccessResult. This can carry a reason and cacheability data. Chris@18: * @param \Drupal\Core\Url $via_link Chris@18: * The source URL for the errors of the response. Chris@18: * @param string|null $relationship_field_name Chris@18: * (optional) The field name to which the forbidden result applies. Useful Chris@18: * for testing related/relationship routes and includes. Chris@18: * @param string|null $detail Chris@18: * (optional) Details for the JSON:API error object. Chris@18: * @param string|bool|null $pointer Chris@18: * (optional) Document pointer for the JSON:API error object. FALSE to omit Chris@18: * the pointer. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The forbidden ResourceResponse. Chris@18: */ Chris@18: protected static function getAccessDeniedResponse(EntityInterface $entity, AccessResultInterface $access, Url $via_link, $relationship_field_name = NULL, $detail = NULL, $pointer = NULL) { Chris@18: $detail = ($detail) ? $detail : 'The current user is not allowed to GET the selected resource.'; Chris@18: if ($access instanceof AccessResultReasonInterface && ($reason = $access->getReason())) { Chris@18: $detail .= ' ' . $reason; Chris@18: } Chris@18: $error = [ Chris@18: 'status' => '403', Chris@18: 'title' => 'Forbidden', Chris@18: 'detail' => $detail, Chris@18: 'links' => [ Chris@18: 'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)], Chris@18: ], Chris@18: ]; Chris@18: if ($pointer || $pointer !== FALSE && $relationship_field_name) { Chris@18: $error['source']['pointer'] = ($pointer) ? $pointer : $relationship_field_name; Chris@18: } Chris@18: if ($via_link) { Chris@18: $error['links']['via']['href'] = $via_link->setAbsolute()->toString(); Chris@18: } Chris@18: Chris@18: return (new ResourceResponse([ Chris@18: 'jsonapi' => static::$jsonApiMember, Chris@18: 'errors' => [$error], Chris@18: ], 403)) Chris@18: ->addCacheableDependency((new CacheableMetadata())->addCacheTags(['4xx-response', 'http_response'])->addCacheContexts(['url.site'])) Chris@18: ->addCacheableDependency($access); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets a generic empty collection response. Chris@18: * Chris@18: * @param int $cardinality Chris@18: * The cardinality of the resource collection. 1 for a to-one related Chris@18: * resource collection; -1 for an unlimited cardinality. Chris@18: * @param string $self_link Chris@18: * The self link for collection ResourceResponse. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The empty collection ResourceResponse. Chris@18: */ Chris@18: protected function getEmptyCollectionResponse($cardinality, $self_link) { Chris@18: // If the entity type is revisionable, add a resource version cache context. Chris@18: $cache_contexts = Cache::mergeContexts([ Chris@18: // Cache contexts for JSON:API URL query parameters. Chris@18: 'url.query_args:fields', Chris@18: 'url.query_args:include', Chris@18: // Drupal defaults. Chris@18: 'url.site', Chris@18: ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []); Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']); Chris@18: return (new ResourceResponse([ Chris@18: // Empty to-one relationships should be NULL and empty to-many Chris@18: // relationships should be an empty array. Chris@18: 'data' => $cardinality === 1 ? NULL : [], Chris@18: 'jsonapi' => static::$jsonApiMember, Chris@18: 'links' => ['self' => ['href' => $self_link]], Chris@18: ]))->addCacheableDependency($cacheability); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Add the omitted object to the document or merges it if one already exists. Chris@18: * Chris@18: * @param array $document Chris@18: * The JSON:API response document. Chris@18: * @param array $omitted Chris@18: * The omitted object. Chris@18: */ Chris@18: protected static function addOmittedObject(array &$document, array $omitted) { Chris@18: if (isset($document['meta']['omitted'])) { Chris@18: $document['meta']['omitted'] = static::mergeOmittedObjects($document['meta']['omitted'], $omitted); Chris@18: } Chris@18: else { Chris@18: $document['meta']['omitted'] = $omitted; Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Maps error objects into an omitted object. Chris@18: * Chris@18: * @param array $errors Chris@18: * An array of error objects. Chris@18: * Chris@18: * @return array Chris@18: * A new omitted object. Chris@18: */ Chris@18: protected static function errorsToOmittedObject(array $errors) { Chris@18: $omitted = [ Chris@18: 'detail' => 'Some resources have been omitted because of insufficient authorization.', Chris@18: 'links' => [ Chris@18: 'help' => [ Chris@18: 'href' => 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control', Chris@18: ], Chris@18: ], Chris@18: ]; Chris@18: foreach ($errors as $error) { Chris@18: $omitted['links']['item:' . substr(Crypt::hashBase64($error['links']['via']['href']), 0, 7)] = [ Chris@18: 'href' => $error['links']['via']['href'], Chris@18: 'meta' => [ Chris@18: 'detail' => $error['detail'], Chris@18: 'rel' => 'item', Chris@18: ], Chris@18: ]; Chris@18: } Chris@18: return $omitted; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Merges the links of two omitted objects and returns a new omitted object. Chris@18: * Chris@18: * @param array $a Chris@18: * The first omitted object. Chris@18: * @param array $b Chris@18: * The second omitted object. Chris@18: * Chris@18: * @return mixed Chris@18: * A new, merged omitted object. Chris@18: */ Chris@18: protected static function mergeOmittedObjects(array $a, array $b) { Chris@18: $merged['detail'] = 'Some resources have been omitted because of insufficient authorization.'; Chris@18: $merged['links']['help']['href'] = 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control'; Chris@18: $a_links = array_diff_key($a['links'], array_flip(['help'])); Chris@18: $b_links = array_diff_key($b['links'], array_flip(['help'])); Chris@18: foreach (array_merge(array_values($a_links), array_values($b_links)) as $link) { Chris@18: $merged['links'][$link['href'] . $link['meta']['detail']] = $link; Chris@18: } Chris@18: static::resetOmittedLinkKeys($merged); Chris@18: return $merged; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Sorts an omitted link object array by href. Chris@18: * Chris@18: * @param array $omitted Chris@18: * An array of JSON:API omitted link objects. Chris@18: */ Chris@18: protected static function sortOmittedLinks(array &$omitted) { Chris@18: $help = $omitted['links']['help']; Chris@18: $links = array_diff_key($omitted['links'], array_flip(['help'])); Chris@18: uasort($links, function ($a, $b) { Chris@18: return strcmp($a['href'], $b['href']); Chris@18: }); Chris@18: $omitted['links'] = ['help' => $help] + $links; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Resets omitted link keys. Chris@18: * Chris@18: * Omitted link keys are a link relation type + a random string. This string Chris@18: * is meaningless and only serves to differentiate link objects. Given that Chris@18: * these are random, we can't assert their value. Chris@18: * Chris@18: * @param array $omitted Chris@18: * An array of JSON:API omitted link objects. Chris@18: */ Chris@18: protected static function resetOmittedLinkKeys(array &$omitted) { Chris@18: $help = $omitted['links']['help']; Chris@18: $reindexed = []; Chris@18: $links = array_diff_key($omitted['links'], array_flip(['help'])); Chris@18: foreach (array_values($links) as $index => $link) { Chris@18: $reindexed['item:' . $index] = $link; Chris@18: } Chris@18: $omitted['links'] = ['help' => $help] + $reindexed; Chris@18: } Chris@18: Chris@18: }