trait ResourceResponseTestTrait in JSON:API 8
Same name and namespace in other branches
- 8.2 tests/src/Functional/ResourceResponseTestTrait.php \Drupal\Tests\jsonapi\Functional\ResourceResponseTestTrait
Utility methods for handling resource responses.
@internal
Hierarchy
- trait \Drupal\Tests\jsonapi\Functional\ResourceResponseTestTrait
File
- tests/
src/ Functional/ ResourceResponseTestTrait.php, line 21
Namespace
Drupal\Tests\jsonapi\FunctionalView source
trait ResourceResponseTestTrait {
/**
* Merges individual responses into a collection response.
*
* Here, a collection response refers to a response with multiple resource
* objects. Not necessarily to a response to a collection route. In both
* cases, the document should indistinguishable.
*
* @param array $responses
* An array or ResourceResponses to be merged.
* @param string|null $self_link
* The self link for the merged document if one should be set.
* @param bool $is_multiple
* Whether the responses are for a multiple cardinality field. This cannot
* be deduced from the number of responses, because a multiple cardinality
* field may have only one value.
*
* @return \Drupal\jsonapi\ResourceResponse
* The merged ResourceResponse.
*/
protected static function toCollectionResourceResponse(array $responses, $self_link, $is_multiple) {
assert(count($responses) > 0);
$merged_document = [];
$merged_cacheability = new CacheableMetadata();
foreach ($responses as $response) {
$response_document = $response
->getResponseData();
$merge_errors = function ($errors) use (&$merged_document, $is_multiple) {
foreach ($errors as $error) {
if ($is_multiple) {
$merged_document['meta']['errors'][] = $error;
}
else {
$merged_document['errors'][] = $error;
}
}
};
// If any of the response documents had top-level or meta errors, we
// should later expect the merged document to have all these errors
// under the 'meta' member.
if (!empty($response_document['errors'])) {
$merge_errors($response_document['errors']);
}
if (!empty($response_document['meta']['errors'])) {
$merge_errors($response_document['meta']['errors']);
}
elseif (isset($response_document['data'])) {
$response_data = $response_document['data'];
if (!isset($merged_document['data'])) {
$merged_document['data'] = static::isResourceIdentifier($response_data) && $is_multiple ? [
$response_data,
] : $response_data;
}
else {
$response_resources = static::isResourceIdentifier($response_data) ? [
$response_data,
] : $response_data;
foreach ($response_resources as $response_resource) {
$merged_document['data'][] = $response_resource;
}
}
}
$merged_cacheability
->addCacheableDependency($response
->getCacheableMetadata());
}
// Until we can reasonably know what caused an error, we shouldn't include
// 'self' links in error documents. For example, a 404 shouldn't have a
// 'self' link because HATEOAS links shouldn't point to resources which do
// not exist.
if (isset($merged_document['errors'])) {
unset($merged_document['links']);
}
else {
$merged_document['links'] = [
'self' => $self_link,
];
// @todo Assign this to every document, even with errors in https://www.drupal.org/project/jsonapi/issues/2949807
$merged_document['jsonapi'] = [
'meta' => [
'links' => [
'self' => 'http://jsonapi.org/format/1.0/',
],
],
'version' => '1.0',
];
}
// If any successful code exists, use that one. Partial success isn't
// defined by HTTP semantics. When different response codes exist, fall
// back to a more general code. Any one success will make the merged request
// a success.
$merged_response_code = array_reduce($responses, function ($merged_response_code, $response) {
$response_code = $response
->getStatusCode();
assert($response_code >= 200 && $response_code < 500, 'Responses must be valid and complete to be merged.');
assert(!($response_code >= 300 && $response_code < 400), 'Redirect responses cannot be merged.');
// In the initial case, use the first response code.
if (is_null($merged_response_code)) {
return $response_code;
}
elseif ($merged_response_code === $response_code) {
return $merged_response_code;
}
elseif ($response_code >= 200 && $response_code < 300 || $merged_response_code >= 200 && $merged_response_code < 300) {
return 200;
}
else {
return 400;
}
}, NULL);
return (new ResourceResponse($merged_document, $merged_response_code))
->addCacheableDependency($merged_cacheability);
}
/**
* Gets an array of expected ResourceResponses for the given include paths.
*
* @param array $include_paths
* The list of relationship include paths for which to get expected data.
* @param array $request_options
* Request options to apply.
*
* @return \Drupal\jsonapi\ResourceResponse
* The expected ResourceResponse.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getExpectedIncludedResourceResponse(array $include_paths, array $request_options) {
$resource_data = array_reduce($include_paths, function ($data, $path) use ($request_options) {
$field_names = explode('.', $path);
$entity = $this->entity;
foreach ($field_names as $field_name) {
$collected_responses = [];
$field_access = static::entityFieldAccess($entity, $field_name, 'view', $this->account);
if (!$field_access
->isAllowed()) {
$collected_responses[] = static::getAccessDeniedResponse($entity, $field_access, $field_name, 'The current user is not allowed to view this relationship.');
break;
}
if ($target_entity = $entity->{$field_name}->entity) {
$target_access = static::entityAccess($target_entity, 'view', $this->account);
if (!$target_access
->isAllowed()) {
$resource_identifier = static::toResourceIdentifier($target_entity);
if (!static::collectionHasResourceIdentifier($resource_identifier, $data['already_checked'])) {
$data['already_checked'][] = $resource_identifier;
// @todo remove this in https://www.drupal.org/project/jsonapi/issues/2943176
$error_id = '/' . $resource_identifier['type'] . '/' . $resource_identifier['id'];
$collected_responses[] = static::getAccessDeniedResponse($entity, $target_access, NULL, NULL, '/data', $error_id);
}
break;
}
}
$psr_responses = $this
->getResponses([
static::getRelatedLink(static::toResourceIdentifier($entity), $field_name),
], $request_options);
$collected_responses[] = static::toCollectionResourceResponse(static::toResourceResponses($psr_responses), NULL, TRUE);
$entity = $entity->{$field_name}->entity;
}
if (!empty($collected_responses)) {
$data['responses'][$path] = static::toCollectionResourceResponse($collected_responses, NULL, TRUE);
}
return $data;
}, [
'responses' => [],
'already_checked' => [],
]);
return static::toCollectionResourceResponse($resource_data['responses'], NULL, TRUE);
}
/**
* Maps an array of PSR responses to JSON API ResourceResponses.
*
* @param \Psr\Http\Message\ResponseInterface[] $responses
* The PSR responses to be mapped.
*
* @return \Drupal\jsonapi\ResourceResponse[]
* The ResourceResponses.
*/
protected static function toResourceResponses(array $responses) {
return array_map([
self::class,
'toResourceResponse',
], $responses);
}
/**
* Maps a response object to a JSON API ResourceResponse.
*
* This helper can be used to ease comparing, recording and merging
* cacheable responses and to have easier access to the JSON API document as
* an array instead of a string.
*
* @param \Psr\Http\Message\ResponseInterface $response
* A PSR response to be mapped.
*
* @return \Drupal\jsonapi\ResourceResponse
* The ResourceResponse.
*/
protected static function toResourceResponse(ResponseInterface $response) {
$cacheability = new CacheableMetadata();
if ($cache_tags = $response
->getHeader('X-Drupal-Cache-Tags')) {
$cacheability
->addCacheTags(explode(' ', $cache_tags[0]));
}
if ($cache_contexts = $response
->getHeader('X-Drupal-Cache-Contexts')) {
$cacheability
->addCacheContexts(explode(' ', $cache_contexts[0]));
}
if ($dynamic_cache = $response
->getHeader('X-Drupal-Dynamic-Cache')) {
$cacheability
->setCacheMaxAge($dynamic_cache[0] === 'UNCACHEABLE' ? 0 : Cache::PERMANENT);
}
$related_document = Json::decode($response
->getBody());
$resource_response = new ResourceResponse($related_document, $response
->getStatusCode());
return $resource_response
->addCacheableDependency($cacheability);
}
/**
* Maps an entity to a resource identifier.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to map to a resource identifier.
*
* @return array
* A resource identifier for the given entity.
*/
protected static function toResourceIdentifier(EntityInterface $entity) {
return [
'type' => $entity
->getEntityTypeId() . '--' . $entity
->bundle(),
'id' => $entity
->uuid(),
];
}
/**
* Checks if a given array is a resource identifier.
*
* @param array $data
* An array to check.
*
* @return bool
* TRUE if the array has a type and ID, FALSE otherwise.
*/
protected static function isResourceIdentifier(array $data) {
return array_key_exists('type', $data) && array_key_exists('id', $data);
}
/**
* Sorts a collection of resources or resource identifiers.
*
* This is useful for asserting collections or resources where order cannot
* be known in advance.
*
* @param array $resources
* The resource or resource identifier.
*/
protected static function sortResourceCollection(array &$resources) {
usort($resources, function ($a, $b) {
return strcmp("{$a['type']}:{$a['id']}", "{$b['type']}:{$b['id']}");
});
}
/**
* Determines if a given resource exists in a list of resources.
*
* @param array $needle
* The resource or resource identifier.
* @param array $haystack
* The list of resources or resource identifiers to search.
*
* @return bool
* TRUE if the needle exists is present in the haystack, FALSE otherwise.
*/
protected static function collectionHasResourceIdentifier(array $needle, array $haystack) {
foreach ($haystack as $resource) {
if ($resource['type'] == $needle['type'] && $resource['id'] == $needle['id']) {
return TRUE;
}
}
return FALSE;
}
/**
* Turns a list of relationship field names into an array of link paths.
*
* @param array $relationship_field_names
* The relationships field names for which to build link paths.
* @param string $type
* The type of link to get. Either 'relationship' or 'related'.
*
* @return array
* An array of link paths, keyed by relationship field name.
*/
protected static function getLinkPaths(array $relationship_field_names, $type) {
assert($type === 'relationship' || $type === 'related');
return array_reduce($relationship_field_names, function ($link_paths, $relationship_field_name) use ($type) {
$tail = $type === 'relationship' ? 'self' : $type;
$link_paths[$relationship_field_name] = "data.relationships.{$relationship_field_name}.links.{$tail}";
return $link_paths;
}, []);
}
/**
* Extracts links from a document using a list of relationship field names.
*
* @param array $link_paths
* A list of paths to link values keyed by a name.
* @param array $document
* A JSON API document.
*
* @return array
* The extracted links, keyed by the original associated key name.
*/
protected static function extractLinks(array $link_paths, array $document) {
return array_map(function ($link_path) use ($document) {
$link = array_reduce(explode('.', $link_path), 'array_column', [
$document,
]);
return $link ? reset($link) : NULL;
}, $link_paths);
}
/**
* Creates individual resource links for a list of resource identifiers.
*
* @param array $resource_identifiers
* A list of resource identifiers for which to create links.
*
* @return string[]
* The resource links.
*/
protected static function getResourceLinks(array $resource_identifiers) {
return array_map([
static::class,
'getResourceLink',
], $resource_identifiers);
}
/**
* Creates an individual resource link for a given resource identifier.
*
* @param array $resource_identifier
* A resource identifier for which to create a link.
*
* @return string
* The resource link.
*/
protected static function getResourceLink(array $resource_identifier) {
assert(static::isResourceIdentifier($resource_identifier));
$resource_type = $resource_identifier['type'];
$resource_id = $resource_identifier['id'];
$entity_type_id = explode('--', $resource_type)[0];
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', $resource_type), [
$entity_type_id => $resource_id,
]);
return $url
->setAbsolute()
->toString();
}
/**
* Creates a relationship link for a given resource identifier and field.
*
* @param array $resource_identifier
* A resource identifier for which to create a link.
* @param string $relationship_field_name
* The relationship field for which to create a link.
*
* @return string
* The relationship link.
*/
protected static function getRelationshipLink(array $resource_identifier, $relationship_field_name) {
return static::getResourceLink($resource_identifier) . "/relationships/{$relationship_field_name}";
}
/**
* Creates a related resource link for a given resource identifier and field.
*
* @param array $resource_identifier
* A resource identifier for which to create a link.
* @param string $relationship_field_name
* The relationship field for which to create a link.
*
* @return string
* The related resource link.
*/
protected static function getRelatedLink(array $resource_identifier, $relationship_field_name) {
return static::getResourceLink($resource_identifier) . "/{$relationship_field_name}";
}
/**
* Gets an array of related responses for the given field names.
*
* @param array $relationship_field_names
* The list of relationship field names for which to get responses.
* @param array $request_options
* Request options to apply.
* @param \Drupal\Core\Entity\EntityInterface|null $entity
* (optional) The entity for which to get expected related responses.
*
* @return array
* The related responses, keyed by relationship field names.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
$entity = $entity ?: $this->entity;
$links = array_map(function ($relationship_field_name) use ($entity) {
return static::getRelatedLink(static::toResourceIdentifier($entity), $relationship_field_name);
}, array_combine($relationship_field_names, $relationship_field_names));
return $this
->getResponses($links, $request_options);
}
/**
* Gets an array of relationship responses for the given field names.
*
* @param array $relationship_field_names
* The list of relationship field names for which to get responses.
* @param array $request_options
* Request options to apply.
*
* @return array
* The relationship responses, keyed by relationship field names.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getRelationshipResponses(array $relationship_field_names, array $request_options) {
$links = array_map(function ($relationship_field_name) {
return static::getRelationshipLink(static::toResourceIdentifier($this->entity), $relationship_field_name);
}, array_combine($relationship_field_names, $relationship_field_names));
return $this
->getResponses($links, $request_options);
}
/**
* Gets responses from an array of links.
*
* @param array $links
* A keyed array of links.
* @param array $request_options
* Request options to apply.
*
* @return array
* The fetched array of responses, keys are preserved.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getResponses(array $links, array $request_options) {
return array_reduce(array_keys($links), function ($related_responses, $key) use ($links, $request_options) {
$related_responses[$key] = $this
->request('GET', Url::fromUri($links[$key]), $request_options);
return $related_responses;
}, []);
}
/**
* Gets a generic forbidden response.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity for which to generate the forbidden response.
* @param \Drupal\Core\Access\AccessResultInterface $access
* The denied AccessResult. This can carry a reason and cacheability data.
* @param string|null $relationship_field_name
* (optional) The field name to which the forbidden result applies. Useful
* for testing related/relationship routes and includes.
* @param string|null $detail
* (optional) Details for the JSON API error object.
* @param string|null $pointer
* (optional) Document pointer for the JSON API error object.
* @param string|null $id
* (optional) ID for the JSON API error object.
*
* @return \Drupal\jsonapi\ResourceResponse
* The forbidden ResourceResponse.
*/
protected static function getAccessDeniedResponse(EntityInterface $entity, AccessResultInterface $access, $relationship_field_name = NULL, $detail = NULL, $pointer = NULL, $id = NULL) {
$detail = $detail ? $detail : 'The current user is not allowed to GET the selected resource.';
if ($access instanceof AccessResultReasonInterface && ($reason = $access
->getReason())) {
$detail .= ' ' . $reason;
}
$resource_identifier = static::toResourceIdentifier($entity);
$error = [
'status' => 403,
'title' => 'Forbidden',
'detail' => $detail,
'links' => [
'info' => HttpExceptionNormalizer::getInfoUrl(403),
],
'code' => 0,
];
if (!is_null($id)) {
$error['id'] = $id;
}
if ($relationship_field_name || $pointer) {
$error['source']['pointer'] = $pointer ? $pointer : $relationship_field_name;
}
return (new ResourceResponse([
'errors' => [
$error,
],
], 403))
->addCacheableDependency($access);
}
}
Members
Name | Modifiers | Type | Description | Overrides |
---|---|---|---|---|
ResourceResponseTestTrait:: |
protected static | function | Determines if a given resource exists in a list of resources. | |
ResourceResponseTestTrait:: |
protected static | function | Extracts links from a document using a list of relationship field names. | |
ResourceResponseTestTrait:: |
protected static | function | Gets a generic forbidden response. | |
ResourceResponseTestTrait:: |
protected | function | Gets an array of expected ResourceResponses for the given include paths. | |
ResourceResponseTestTrait:: |
protected static | function | Turns a list of relationship field names into an array of link paths. | |
ResourceResponseTestTrait:: |
protected static | function | Creates a related resource link for a given resource identifier and field. | |
ResourceResponseTestTrait:: |
protected | function | Gets an array of related responses for the given field names. | |
ResourceResponseTestTrait:: |
protected static | function | Creates a relationship link for a given resource identifier and field. | |
ResourceResponseTestTrait:: |
protected | function | Gets an array of relationship responses for the given field names. | |
ResourceResponseTestTrait:: |
protected static | function | Creates an individual resource link for a given resource identifier. | |
ResourceResponseTestTrait:: |
protected static | function | Creates individual resource links for a list of resource identifiers. | |
ResourceResponseTestTrait:: |
protected | function | Gets responses from an array of links. | |
ResourceResponseTestTrait:: |
protected static | function | Checks if a given array is a resource identifier. | |
ResourceResponseTestTrait:: |
protected static | function | Sorts a collection of resources or resource identifiers. | |
ResourceResponseTestTrait:: |
protected static | function | Merges individual responses into a collection response. | |
ResourceResponseTestTrait:: |
protected static | function | Maps an entity to a resource identifier. | |
ResourceResponseTestTrait:: |
protected static | function | Maps a response object to a JSON API ResourceResponse. | |
ResourceResponseTestTrait:: |
protected static | function | Maps an array of PSR responses to JSON API ResourceResponses. |