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 }
|