comparison core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php @ 5:12f9dff5fda9 tip

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