annotate core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php @ 19:fa3358dc1485 tip

Add ndrum files
author Chris Cannam
date Wed, 28 Aug 2019 13:14:47 +0100
parents af1871eacc83
children
rev   line source
Chris@18 1 <?php
Chris@18 2
Chris@18 3 namespace Drupal\Tests\jsonapi\Functional;
Chris@18 4
Chris@18 5 use Drupal\Component\Serialization\Json;
Chris@18 6 use Drupal\Component\Utility\Crypt;
Chris@18 7 use Drupal\Core\Access\AccessResultInterface;
Chris@18 8 use Drupal\Core\Access\AccessResultReasonInterface;
Chris@18 9 use Drupal\Core\Cache\Cache;
Chris@18 10 use Drupal\Core\Cache\CacheableMetadata;
Chris@18 11 use Drupal\Core\Entity\EntityInterface;
Chris@18 12 use Drupal\Core\Entity\RevisionableInterface;
Chris@18 13 use Drupal\Core\Url;
Chris@18 14 use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
Chris@18 15 use Drupal\jsonapi\ResourceResponse;
Chris@18 16 use Psr\Http\Message\ResponseInterface;
Chris@18 17
Chris@18 18 /**
Chris@18 19 * Utility methods for handling resource responses.
Chris@18 20 *
Chris@18 21 * @internal
Chris@18 22 */
Chris@18 23 trait ResourceResponseTestTrait {
Chris@18 24
Chris@18 25 /**
Chris@18 26 * Merges individual responses into a collection response.
Chris@18 27 *
Chris@18 28 * Here, a collection response refers to a response with multiple resource
Chris@18 29 * objects. Not necessarily to a response to a collection route. In both
Chris@18 30 * cases, the document should indistinguishable.
Chris@18 31 *
Chris@18 32 * @param \Drupal\jsonapi\ResourceResponse[] $responses
Chris@18 33 * An array or ResourceResponses to be merged.
Chris@18 34 * @param string|null $self_link
Chris@18 35 * The self link for the merged document if one should be set.
Chris@18 36 * @param bool $is_multiple
Chris@18 37 * Whether the responses are for a multiple cardinality field. This cannot
Chris@18 38 * be deduced from the number of responses, because a multiple cardinality
Chris@18 39 * field may have only one value.
Chris@18 40 *
Chris@18 41 * @return \Drupal\jsonapi\ResourceResponse
Chris@18 42 * The merged ResourceResponse.
Chris@18 43 */
Chris@18 44 protected static function toCollectionResourceResponse(array $responses, $self_link, $is_multiple) {
Chris@18 45 assert(count($responses) > 0);
Chris@18 46 $merged_document = [];
Chris@18 47 $merged_cacheability = new CacheableMetadata();
Chris@18 48 foreach ($responses as $response) {
Chris@18 49 $response_document = $response->getResponseData();
Chris@18 50 // If any of the response documents had top-level errors, we should later
Chris@18 51 // expect the merged document to have all errors as omitted links under
Chris@18 52 // the 'meta.omitted' member.
Chris@18 53 if (!empty($response_document['errors'])) {
Chris@18 54 static::addOmittedObject($merged_document, static::errorsToOmittedObject($response_document['errors']));
Chris@18 55 }
Chris@18 56 if (!empty($response_document['meta']['omitted'])) {
Chris@18 57 static::addOmittedObject($merged_document, $response_document['meta']['omitted']);
Chris@18 58 }
Chris@18 59 elseif (isset($response_document['data'])) {
Chris@18 60 $response_data = $response_document['data'];
Chris@18 61 if (!isset($merged_document['data'])) {
Chris@18 62 $merged_document['data'] = static::isResourceIdentifier($response_data) && $is_multiple
Chris@18 63 ? [$response_data]
Chris@18 64 : $response_data;
Chris@18 65 }
Chris@18 66 else {
Chris@18 67 $response_resources = static::isResourceIdentifier($response_data)
Chris@18 68 ? [$response_data]
Chris@18 69 : $response_data;
Chris@18 70 foreach ($response_resources as $response_resource) {
Chris@18 71 $merged_document['data'][] = $response_resource;
Chris@18 72 }
Chris@18 73 }
Chris@18 74 }
Chris@18 75 $merged_cacheability->addCacheableDependency($response->getCacheableMetadata());
Chris@18 76 }
Chris@18 77 $merged_document['jsonapi'] = [
Chris@18 78 'meta' => [
Chris@18 79 'links' => [
Chris@18 80 'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
Chris@18 81 ],
Chris@18 82 ],
Chris@18 83 'version' => '1.0',
Chris@18 84 ];
Chris@18 85 // Until we can reasonably know what caused an error, we shouldn't include
Chris@18 86 // 'self' links in error documents. For example, a 404 shouldn't have a
Chris@18 87 // 'self' link because HATEOAS links shouldn't point to resources which do
Chris@18 88 // not exist.
Chris@18 89 if (isset($merged_document['errors'])) {
Chris@18 90 unset($merged_document['links']);
Chris@18 91 }
Chris@18 92 else {
Chris@18 93 if (!isset($merged_document['data'])) {
Chris@18 94 $merged_document['data'] = $is_multiple ? [] : NULL;
Chris@18 95 }
Chris@18 96 $merged_document['links'] = [
Chris@18 97 'self' => [
Chris@18 98 'href' => $self_link,
Chris@18 99 ],
Chris@18 100 ];
Chris@18 101 }
Chris@18 102 // All collections should be 200, without regard for the status of the
Chris@18 103 // individual resources in those collections, which means any '4xx-response'
Chris@18 104 // cache tags on the individual responses should also be omitted.
Chris@18 105 $merged_cacheability->setCacheTags(array_diff($merged_cacheability->getCacheTags(), ['4xx-response']));
Chris@18 106 return (new ResourceResponse($merged_document, 200))->addCacheableDependency($merged_cacheability);
Chris@18 107 }
Chris@18 108
Chris@18 109 /**
Chris@18 110 * Gets an array of expected ResourceResponses for the given include paths.
Chris@18 111 *
Chris@18 112 * @param array $include_paths
Chris@18 113 * The list of relationship include paths for which to get expected data.
Chris@18 114 * @param array $request_options
Chris@18 115 * Request options to apply.
Chris@18 116 *
Chris@18 117 * @return \Drupal\jsonapi\ResourceResponse
Chris@18 118 * The expected ResourceResponse.
Chris@18 119 *
Chris@18 120 * @see \GuzzleHttp\ClientInterface::request()
Chris@18 121 */
Chris@18 122 protected function getExpectedIncludedResourceResponse(array $include_paths, array $request_options) {
Chris@18 123 $resource_type = $this->resourceType;
Chris@18 124 $resource_data = array_reduce($include_paths, function ($data, $path) use ($request_options, $resource_type) {
Chris@18 125 $field_names = explode('.', $path);
Chris@18 126 /* @var \Drupal\Core\Entity\EntityInterface $entity */
Chris@18 127 $entity = $this->entity;
Chris@18 128 $collected_responses = [];
Chris@18 129 foreach ($field_names as $public_field_name) {
Chris@18 130 $resource_type = $this->container->get('jsonapi.resource_type.repository')->get($entity->getEntityTypeId(), $entity->bundle());
Chris@18 131 $field_name = $resource_type->getInternalName($public_field_name);
Chris@18 132 $field_access = static::entityFieldAccess($entity, $field_name, 'view', $this->account);
Chris@18 133 if (!$field_access->isAllowed()) {
Chris@18 134 if (!$entity->access('view') && $entity->access('view label') && $field_access instanceof AccessResultReasonInterface && empty($field_access->getReason())) {
Chris@18 135 $field_access->setReason("The user only has authorization for the 'view label' operation.");
Chris@18 136 }
Chris@18 137 $via_link = Url::fromRoute(
Chris@18 138 sprintf('jsonapi.%s.%s.related', $entity->getEntityTypeId() . '--' . $entity->bundle(), $public_field_name),
Chris@18 139 ['entity' => $entity->uuid()]
Chris@18 140 );
Chris@18 141 $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 142 break;
Chris@18 143 }
Chris@18 144 if ($target_entity = $entity->{$field_name}->entity) {
Chris@18 145 $target_access = static::entityAccess($target_entity, 'view', $this->account);
Chris@18 146 if (!$target_access->isAllowed()) {
Chris@18 147 $target_access = static::entityAccess($target_entity, 'view label', $this->account)->addCacheableDependency($target_access);
Chris@18 148 }
Chris@18 149 if (!$target_access->isAllowed()) {
Chris@18 150 $resource_identifier = static::toResourceIdentifier($target_entity);
Chris@18 151 if (!static::collectionHasResourceIdentifier($resource_identifier, $data['already_checked'])) {
Chris@18 152 $data['already_checked'][] = $resource_identifier;
Chris@18 153 $via_link = Url::fromRoute(
Chris@18 154 sprintf('jsonapi.%s.individual', $resource_identifier['type']),
Chris@18 155 ['entity' => $resource_identifier['id']]
Chris@18 156 );
Chris@18 157 $collected_responses[] = static::getAccessDeniedResponse($entity, $target_access, $via_link, NULL, NULL, '/data');
Chris@18 158 }
Chris@18 159 break;
Chris@18 160 }
Chris@18 161 }
Chris@18 162 $psr_responses = $this->getResponses([static::getRelatedLink(static::toResourceIdentifier($entity), $public_field_name)], $request_options);
Chris@18 163 $collected_responses[] = static::toCollectionResourceResponse(static::toResourceResponses($psr_responses), NULL, TRUE);
Chris@18 164 $entity = $entity->{$field_name}->entity;
Chris@18 165 }
Chris@18 166 if (!empty($collected_responses)) {
Chris@18 167 $data['responses'][$path] = static::toCollectionResourceResponse($collected_responses, NULL, TRUE);
Chris@18 168 }
Chris@18 169 return $data;
Chris@18 170 }, ['responses' => [], 'already_checked' => []]);
Chris@18 171
Chris@18 172 $individual_document = $this->getExpectedDocument();
Chris@18 173
Chris@18 174 $expected_base_url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute();
Chris@18 175 $include_url = clone $expected_base_url;
Chris@18 176 $query = ['include' => implode(',', $include_paths)];
Chris@18 177 $include_url->setOption('query', $query);
Chris@18 178 $individual_document['links']['self']['href'] = $include_url->toString();
Chris@18 179
Chris@18 180 // The test entity reference field should always be present.
Chris@18 181 if (!isset($individual_document['data']['relationships']['field_jsonapi_test_entity_ref'])) {
Chris@18 182 if (static::$resourceTypeIsVersionable) {
Chris@18 183 assert($this->entity instanceof RevisionableInterface);
Chris@18 184 $version_identifier = 'id:' . $this->entity->getRevisionId();
Chris@18 185 $version_query_string = '?resourceVersion=' . urlencode($version_identifier);
Chris@18 186 }
Chris@18 187 else {
Chris@18 188 $version_query_string = '';
Chris@18 189 }
Chris@18 190 $individual_document['data']['relationships']['field_jsonapi_test_entity_ref'] = [
Chris@18 191 'data' => [],
Chris@18 192 'links' => [
Chris@18 193 'related' => [
Chris@18 194 'href' => $expected_base_url->toString() . '/field_jsonapi_test_entity_ref' . $version_query_string,
Chris@18 195 ],
Chris@18 196 'self' => [
Chris@18 197 'href' => $expected_base_url->toString() . '/relationships/field_jsonapi_test_entity_ref' . $version_query_string,
Chris@18 198 ],
Chris@18 199 ],
Chris@18 200 ];
Chris@18 201 }
Chris@18 202
Chris@18 203 $basic_cacheability = (new CacheableMetadata())
Chris@18 204 ->addCacheTags($this->getExpectedCacheTags())
Chris@18 205 ->addCacheContexts($this->getExpectedCacheContexts());
Chris@18 206 return static::decorateExpectedResponseForIncludedFields(ResourceResponse::create($individual_document), $resource_data['responses'])
Chris@18 207 ->addCacheableDependency($basic_cacheability);
Chris@18 208 }
Chris@18 209
Chris@18 210 /**
Chris@18 211 * Maps an array of PSR responses to JSON:API ResourceResponses.
Chris@18 212 *
Chris@18 213 * @param \Psr\Http\Message\ResponseInterface[] $responses
Chris@18 214 * The PSR responses to be mapped.
Chris@18 215 *
Chris@18 216 * @return \Drupal\jsonapi\ResourceResponse[]
Chris@18 217 * The ResourceResponses.
Chris@18 218 */
Chris@18 219 protected static function toResourceResponses(array $responses) {
Chris@18 220 return array_map([self::class, 'toResourceResponse'], $responses);
Chris@18 221 }
Chris@18 222
Chris@18 223 /**
Chris@18 224 * Maps a response object to a JSON:API ResourceResponse.
Chris@18 225 *
Chris@18 226 * This helper can be used to ease comparing, recording and merging
Chris@18 227 * cacheable responses and to have easier access to the JSON:API document as
Chris@18 228 * an array instead of a string.
Chris@18 229 *
Chris@18 230 * @param \Psr\Http\Message\ResponseInterface $response
Chris@18 231 * A PSR response to be mapped.
Chris@18 232 *
Chris@18 233 * @return \Drupal\jsonapi\ResourceResponse
Chris@18 234 * The ResourceResponse.
Chris@18 235 */
Chris@18 236 protected static function toResourceResponse(ResponseInterface $response) {
Chris@18 237 $cacheability = new CacheableMetadata();
Chris@18 238 if ($cache_tags = $response->getHeader('X-Drupal-Cache-Tags')) {
Chris@18 239 $cacheability->addCacheTags(explode(' ', $cache_tags[0]));
Chris@18 240 }
Chris@18 241 if (!empty($response->getHeaderLine('X-Drupal-Cache-Contexts'))) {
Chris@18 242 $cacheability->addCacheContexts(explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
Chris@18 243 }
Chris@18 244 if ($dynamic_cache = $response->getHeader('X-Drupal-Dynamic-Cache')) {
Chris@18 245 $cacheability->setCacheMaxAge(($dynamic_cache[0] === 'UNCACHEABLE' && $response->getStatusCode() < 400) ? 0 : Cache::PERMANENT);
Chris@18 246 }
Chris@18 247 $related_document = Json::decode($response->getBody());
Chris@18 248 $resource_response = new ResourceResponse($related_document, $response->getStatusCode());
Chris@18 249 return $resource_response->addCacheableDependency($cacheability);
Chris@18 250 }
Chris@18 251
Chris@18 252 /**
Chris@18 253 * Maps an entity to a resource identifier.
Chris@18 254 *
Chris@18 255 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@18 256 * The entity to map to a resource identifier.
Chris@18 257 *
Chris@18 258 * @return array
Chris@18 259 * A resource identifier for the given entity.
Chris@18 260 */
Chris@18 261 protected static function toResourceIdentifier(EntityInterface $entity) {
Chris@18 262 return [
Chris@18 263 'type' => $entity->getEntityTypeId() . '--' . $entity->bundle(),
Chris@18 264 'id' => $entity->uuid(),
Chris@18 265 ];
Chris@18 266 }
Chris@18 267
Chris@18 268 /**
Chris@18 269 * Checks if a given array is a resource identifier.
Chris@18 270 *
Chris@18 271 * @param array $data
Chris@18 272 * An array to check.
Chris@18 273 *
Chris@18 274 * @return bool
Chris@18 275 * TRUE if the array has a type and ID, FALSE otherwise.
Chris@18 276 */
Chris@18 277 protected static function isResourceIdentifier(array $data) {
Chris@18 278 return array_key_exists('type', $data) && array_key_exists('id', $data);
Chris@18 279 }
Chris@18 280
Chris@18 281 /**
Chris@18 282 * Sorts a collection of resources or resource identifiers.
Chris@18 283 *
Chris@18 284 * This is useful for asserting collections or resources where order cannot
Chris@18 285 * be known in advance.
Chris@18 286 *
Chris@18 287 * @param array $resources
Chris@18 288 * The resource or resource identifier.
Chris@18 289 */
Chris@18 290 protected static function sortResourceCollection(array &$resources) {
Chris@18 291 usort($resources, function ($a, $b) {
Chris@18 292 return strcmp("{$a['type']}:{$a['id']}", "{$b['type']}:{$b['id']}");
Chris@18 293 });
Chris@18 294 }
Chris@18 295
Chris@18 296 /**
Chris@18 297 * Determines if a given resource exists in a list of resources.
Chris@18 298 *
Chris@18 299 * @param array $needle
Chris@18 300 * The resource or resource identifier.
Chris@18 301 * @param array $haystack
Chris@18 302 * The list of resources or resource identifiers to search.
Chris@18 303 *
Chris@18 304 * @return bool
Chris@18 305 * TRUE if the needle exists is present in the haystack, FALSE otherwise.
Chris@18 306 */
Chris@18 307 protected static function collectionHasResourceIdentifier(array $needle, array $haystack) {
Chris@18 308 foreach ($haystack as $resource) {
Chris@18 309 if ($resource['type'] == $needle['type'] && $resource['id'] == $needle['id']) {
Chris@18 310 return TRUE;
Chris@18 311 }
Chris@18 312 }
Chris@18 313 return FALSE;
Chris@18 314 }
Chris@18 315
Chris@18 316 /**
Chris@18 317 * Turns a list of relationship field names into an array of link paths.
Chris@18 318 *
Chris@18 319 * @param array $relationship_field_names
Chris@18 320 * The relationships field names for which to build link paths.
Chris@18 321 * @param string $type
Chris@18 322 * The type of link to get. Either 'relationship' or 'related'.
Chris@18 323 *
Chris@18 324 * @return array
Chris@18 325 * An array of link paths, keyed by relationship field name.
Chris@18 326 */
Chris@18 327 protected static function getLinkPaths(array $relationship_field_names, $type) {
Chris@18 328 assert($type === 'relationship' || $type === 'related');
Chris@18 329 return array_reduce($relationship_field_names, function ($link_paths, $relationship_field_name) use ($type) {
Chris@18 330 $tail = $type === 'relationship' ? 'self' : $type;
Chris@18 331 $link_paths[$relationship_field_name] = "data.relationships.$relationship_field_name.links.$tail.href";
Chris@18 332 return $link_paths;
Chris@18 333 }, []);
Chris@18 334 }
Chris@18 335
Chris@18 336 /**
Chris@18 337 * Extracts links from a document using a list of relationship field names.
Chris@18 338 *
Chris@18 339 * @param array $link_paths
Chris@18 340 * A list of paths to link values keyed by a name.
Chris@18 341 * @param array $document
Chris@18 342 * A JSON:API document.
Chris@18 343 *
Chris@18 344 * @return array
Chris@18 345 * The extracted links, keyed by the original associated key name.
Chris@18 346 */
Chris@18 347 protected static function extractLinks(array $link_paths, array $document) {
Chris@18 348 return array_map(function ($link_path) use ($document) {
Chris@18 349 $link = array_reduce(
Chris@18 350 explode('.', $link_path),
Chris@18 351 'array_column',
Chris@18 352 [$document]
Chris@18 353 );
Chris@18 354 return ($link) ? reset($link) : NULL;
Chris@18 355 }, $link_paths);
Chris@18 356 }
Chris@18 357
Chris@18 358 /**
Chris@18 359 * Creates individual resource links for a list of resource identifiers.
Chris@18 360 *
Chris@18 361 * @param array $resource_identifiers
Chris@18 362 * A list of resource identifiers for which to create links.
Chris@18 363 *
Chris@18 364 * @return string[]
Chris@18 365 * The resource links.
Chris@18 366 */
Chris@18 367 protected static function getResourceLinks(array $resource_identifiers) {
Chris@18 368 return array_map([static::class, 'getResourceLink'], $resource_identifiers);
Chris@18 369 }
Chris@18 370
Chris@18 371 /**
Chris@18 372 * Creates an individual resource link for a given resource identifier.
Chris@18 373 *
Chris@18 374 * @param array $resource_identifier
Chris@18 375 * A resource identifier for which to create a link.
Chris@18 376 *
Chris@18 377 * @return string
Chris@18 378 * The resource link.
Chris@18 379 */
Chris@18 380 protected static function getResourceLink(array $resource_identifier) {
Chris@18 381 assert(static::isResourceIdentifier($resource_identifier));
Chris@18 382 $resource_type = $resource_identifier['type'];
Chris@18 383 $resource_id = $resource_identifier['id'];
Chris@18 384 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', $resource_type), ['entity' => $resource_id]);
Chris@18 385 return $url->setAbsolute()->toString();
Chris@18 386 }
Chris@18 387
Chris@18 388 /**
Chris@18 389 * Creates a relationship link for a given resource identifier and field.
Chris@18 390 *
Chris@18 391 * @param array $resource_identifier
Chris@18 392 * A resource identifier for which to create a link.
Chris@18 393 * @param string $relationship_field_name
Chris@18 394 * The relationship field for which to create a link.
Chris@18 395 *
Chris@18 396 * @return string
Chris@18 397 * The relationship link.
Chris@18 398 */
Chris@18 399 protected static function getRelationshipLink(array $resource_identifier, $relationship_field_name) {
Chris@18 400 return static::getResourceLink($resource_identifier) . "/relationships/$relationship_field_name";
Chris@18 401 }
Chris@18 402
Chris@18 403 /**
Chris@18 404 * Creates a related resource link for a given resource identifier and field.
Chris@18 405 *
Chris@18 406 * @param array $resource_identifier
Chris@18 407 * A resource identifier for which to create a link.
Chris@18 408 * @param string $relationship_field_name
Chris@18 409 * The relationship field for which to create a link.
Chris@18 410 *
Chris@18 411 * @return string
Chris@18 412 * The related resource link.
Chris@18 413 */
Chris@18 414 protected static function getRelatedLink(array $resource_identifier, $relationship_field_name) {
Chris@18 415 return static::getResourceLink($resource_identifier) . "/$relationship_field_name";
Chris@18 416 }
Chris@18 417
Chris@18 418 /**
Chris@18 419 * Gets an array of related responses for the given field names.
Chris@18 420 *
Chris@18 421 * @param array $relationship_field_names
Chris@18 422 * The list of relationship field names for which to get responses.
Chris@18 423 * @param array $request_options
Chris@18 424 * Request options to apply.
Chris@18 425 * @param \Drupal\Core\Entity\EntityInterface|null $entity
Chris@18 426 * (optional) The entity for which to get expected related responses.
Chris@18 427 *
Chris@18 428 * @return array
Chris@18 429 * The related responses, keyed by relationship field names.
Chris@18 430 *
Chris@18 431 * @see \GuzzleHttp\ClientInterface::request()
Chris@18 432 */
Chris@18 433 protected function getRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
Chris@18 434 $entity = $entity ?: $this->entity;
Chris@18 435 $links = array_map(function ($relationship_field_name) use ($entity) {
Chris@18 436 return static::getRelatedLink(static::toResourceIdentifier($entity), $relationship_field_name);
Chris@18 437 }, array_combine($relationship_field_names, $relationship_field_names));
Chris@18 438 return $this->getResponses($links, $request_options);
Chris@18 439 }
Chris@18 440
Chris@18 441 /**
Chris@18 442 * Gets an array of relationship responses for the given field names.
Chris@18 443 *
Chris@18 444 * @param array $relationship_field_names
Chris@18 445 * The list of relationship field names for which to get responses.
Chris@18 446 * @param array $request_options
Chris@18 447 * Request options to apply.
Chris@18 448 *
Chris@18 449 * @return array
Chris@18 450 * The relationship responses, keyed by relationship field names.
Chris@18 451 *
Chris@18 452 * @see \GuzzleHttp\ClientInterface::request()
Chris@18 453 */
Chris@18 454 protected function getRelationshipResponses(array $relationship_field_names, array $request_options) {
Chris@18 455 $links = array_map(function ($relationship_field_name) {
Chris@18 456 return static::getRelationshipLink(static::toResourceIdentifier($this->entity), $relationship_field_name);
Chris@18 457 }, array_combine($relationship_field_names, $relationship_field_names));
Chris@18 458 return $this->getResponses($links, $request_options);
Chris@18 459 }
Chris@18 460
Chris@18 461 /**
Chris@18 462 * Gets responses from an array of links.
Chris@18 463 *
Chris@18 464 * @param array $links
Chris@18 465 * A keyed array of links.
Chris@18 466 * @param array $request_options
Chris@18 467 * Request options to apply.
Chris@18 468 *
Chris@18 469 * @return array
Chris@18 470 * The fetched array of responses, keys are preserved.
Chris@18 471 *
Chris@18 472 * @see \GuzzleHttp\ClientInterface::request()
Chris@18 473 */
Chris@18 474 protected function getResponses(array $links, array $request_options) {
Chris@18 475 return array_reduce(array_keys($links), function ($related_responses, $key) use ($links, $request_options) {
Chris@18 476 $related_responses[$key] = $this->request('GET', Url::fromUri($links[$key]), $request_options);
Chris@18 477 return $related_responses;
Chris@18 478 }, []);
Chris@18 479 }
Chris@18 480
Chris@18 481 /**
Chris@18 482 * Gets a generic forbidden response.
Chris@18 483 *
Chris@18 484 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@18 485 * The entity for which to generate the forbidden response.
Chris@18 486 * @param \Drupal\Core\Access\AccessResultInterface $access
Chris@18 487 * The denied AccessResult. This can carry a reason and cacheability data.
Chris@18 488 * @param \Drupal\Core\Url $via_link
Chris@18 489 * The source URL for the errors of the response.
Chris@18 490 * @param string|null $relationship_field_name
Chris@18 491 * (optional) The field name to which the forbidden result applies. Useful
Chris@18 492 * for testing related/relationship routes and includes.
Chris@18 493 * @param string|null $detail
Chris@18 494 * (optional) Details for the JSON:API error object.
Chris@18 495 * @param string|bool|null $pointer
Chris@18 496 * (optional) Document pointer for the JSON:API error object. FALSE to omit
Chris@18 497 * the pointer.
Chris@18 498 *
Chris@18 499 * @return \Drupal\jsonapi\ResourceResponse
Chris@18 500 * The forbidden ResourceResponse.
Chris@18 501 */
Chris@18 502 protected static function getAccessDeniedResponse(EntityInterface $entity, AccessResultInterface $access, Url $via_link, $relationship_field_name = NULL, $detail = NULL, $pointer = NULL) {
Chris@18 503 $detail = ($detail) ? $detail : 'The current user is not allowed to GET the selected resource.';
Chris@18 504 if ($access instanceof AccessResultReasonInterface && ($reason = $access->getReason())) {
Chris@18 505 $detail .= ' ' . $reason;
Chris@18 506 }
Chris@18 507 $error = [
Chris@18 508 'status' => '403',
Chris@18 509 'title' => 'Forbidden',
Chris@18 510 'detail' => $detail,
Chris@18 511 'links' => [
Chris@18 512 'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
Chris@18 513 ],
Chris@18 514 ];
Chris@18 515 if ($pointer || $pointer !== FALSE && $relationship_field_name) {
Chris@18 516 $error['source']['pointer'] = ($pointer) ? $pointer : $relationship_field_name;
Chris@18 517 }
Chris@18 518 if ($via_link) {
Chris@18 519 $error['links']['via']['href'] = $via_link->setAbsolute()->toString();
Chris@18 520 }
Chris@18 521
Chris@18 522 return (new ResourceResponse([
Chris@18 523 'jsonapi' => static::$jsonApiMember,
Chris@18 524 'errors' => [$error],
Chris@18 525 ], 403))
Chris@18 526 ->addCacheableDependency((new CacheableMetadata())->addCacheTags(['4xx-response', 'http_response'])->addCacheContexts(['url.site']))
Chris@18 527 ->addCacheableDependency($access);
Chris@18 528 }
Chris@18 529
Chris@18 530 /**
Chris@18 531 * Gets a generic empty collection response.
Chris@18 532 *
Chris@18 533 * @param int $cardinality
Chris@18 534 * The cardinality of the resource collection. 1 for a to-one related
Chris@18 535 * resource collection; -1 for an unlimited cardinality.
Chris@18 536 * @param string $self_link
Chris@18 537 * The self link for collection ResourceResponse.
Chris@18 538 *
Chris@18 539 * @return \Drupal\jsonapi\ResourceResponse
Chris@18 540 * The empty collection ResourceResponse.
Chris@18 541 */
Chris@18 542 protected function getEmptyCollectionResponse($cardinality, $self_link) {
Chris@18 543 // If the entity type is revisionable, add a resource version cache context.
Chris@18 544 $cache_contexts = Cache::mergeContexts([
Chris@18 545 // Cache contexts for JSON:API URL query parameters.
Chris@18 546 'url.query_args:fields',
Chris@18 547 'url.query_args:include',
Chris@18 548 // Drupal defaults.
Chris@18 549 'url.site',
Chris@18 550 ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
Chris@18 551 $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']);
Chris@18 552 return (new ResourceResponse([
Chris@18 553 // Empty to-one relationships should be NULL and empty to-many
Chris@18 554 // relationships should be an empty array.
Chris@18 555 'data' => $cardinality === 1 ? NULL : [],
Chris@18 556 'jsonapi' => static::$jsonApiMember,
Chris@18 557 'links' => ['self' => ['href' => $self_link]],
Chris@18 558 ]))->addCacheableDependency($cacheability);
Chris@18 559 }
Chris@18 560
Chris@18 561 /**
Chris@18 562 * Add the omitted object to the document or merges it if one already exists.
Chris@18 563 *
Chris@18 564 * @param array $document
Chris@18 565 * The JSON:API response document.
Chris@18 566 * @param array $omitted
Chris@18 567 * The omitted object.
Chris@18 568 */
Chris@18 569 protected static function addOmittedObject(array &$document, array $omitted) {
Chris@18 570 if (isset($document['meta']['omitted'])) {
Chris@18 571 $document['meta']['omitted'] = static::mergeOmittedObjects($document['meta']['omitted'], $omitted);
Chris@18 572 }
Chris@18 573 else {
Chris@18 574 $document['meta']['omitted'] = $omitted;
Chris@18 575 }
Chris@18 576 }
Chris@18 577
Chris@18 578 /**
Chris@18 579 * Maps error objects into an omitted object.
Chris@18 580 *
Chris@18 581 * @param array $errors
Chris@18 582 * An array of error objects.
Chris@18 583 *
Chris@18 584 * @return array
Chris@18 585 * A new omitted object.
Chris@18 586 */
Chris@18 587 protected static function errorsToOmittedObject(array $errors) {
Chris@18 588 $omitted = [
Chris@18 589 'detail' => 'Some resources have been omitted because of insufficient authorization.',
Chris@18 590 'links' => [
Chris@18 591 'help' => [
Chris@18 592 'href' => 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control',
Chris@18 593 ],
Chris@18 594 ],
Chris@18 595 ];
Chris@18 596 foreach ($errors as $error) {
Chris@18 597 $omitted['links']['item:' . substr(Crypt::hashBase64($error['links']['via']['href']), 0, 7)] = [
Chris@18 598 'href' => $error['links']['via']['href'],
Chris@18 599 'meta' => [
Chris@18 600 'detail' => $error['detail'],
Chris@18 601 'rel' => 'item',
Chris@18 602 ],
Chris@18 603 ];
Chris@18 604 }
Chris@18 605 return $omitted;
Chris@18 606 }
Chris@18 607
Chris@18 608 /**
Chris@18 609 * Merges the links of two omitted objects and returns a new omitted object.
Chris@18 610 *
Chris@18 611 * @param array $a
Chris@18 612 * The first omitted object.
Chris@18 613 * @param array $b
Chris@18 614 * The second omitted object.
Chris@18 615 *
Chris@18 616 * @return mixed
Chris@18 617 * A new, merged omitted object.
Chris@18 618 */
Chris@18 619 protected static function mergeOmittedObjects(array $a, array $b) {
Chris@18 620 $merged['detail'] = 'Some resources have been omitted because of insufficient authorization.';
Chris@18 621 $merged['links']['help']['href'] = 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control';
Chris@18 622 $a_links = array_diff_key($a['links'], array_flip(['help']));
Chris@18 623 $b_links = array_diff_key($b['links'], array_flip(['help']));
Chris@18 624 foreach (array_merge(array_values($a_links), array_values($b_links)) as $link) {
Chris@18 625 $merged['links'][$link['href'] . $link['meta']['detail']] = $link;
Chris@18 626 }
Chris@18 627 static::resetOmittedLinkKeys($merged);
Chris@18 628 return $merged;
Chris@18 629 }
Chris@18 630
Chris@18 631 /**
Chris@18 632 * Sorts an omitted link object array by href.
Chris@18 633 *
Chris@18 634 * @param array $omitted
Chris@18 635 * An array of JSON:API omitted link objects.
Chris@18 636 */
Chris@18 637 protected static function sortOmittedLinks(array &$omitted) {
Chris@18 638 $help = $omitted['links']['help'];
Chris@18 639 $links = array_diff_key($omitted['links'], array_flip(['help']));
Chris@18 640 uasort($links, function ($a, $b) {
Chris@18 641 return strcmp($a['href'], $b['href']);
Chris@18 642 });
Chris@18 643 $omitted['links'] = ['help' => $help] + $links;
Chris@18 644 }
Chris@18 645
Chris@18 646 /**
Chris@18 647 * Resets omitted link keys.
Chris@18 648 *
Chris@18 649 * Omitted link keys are a link relation type + a random string. This string
Chris@18 650 * is meaningless and only serves to differentiate link objects. Given that
Chris@18 651 * these are random, we can't assert their value.
Chris@18 652 *
Chris@18 653 * @param array $omitted
Chris@18 654 * An array of JSON:API omitted link objects.
Chris@18 655 */
Chris@18 656 protected static function resetOmittedLinkKeys(array &$omitted) {
Chris@18 657 $help = $omitted['links']['help'];
Chris@18 658 $reindexed = [];
Chris@18 659 $links = array_diff_key($omitted['links'], array_flip(['help']));
Chris@18 660 foreach (array_values($links) as $index => $link) {
Chris@18 661 $reindexed['item:' . $index] = $link;
Chris@18 662 }
Chris@18 663 $omitted['links'] = ['help' => $help] + $reindexed;
Chris@18 664 }
Chris@18 665
Chris@18 666 }