Chris@18
|
1 <?php
|
Chris@18
|
2
|
Chris@18
|
3 namespace Drupal\jsonapi\Revisions;
|
Chris@18
|
4
|
Chris@18
|
5 use Drupal\Core\Cache\CacheableMetadata;
|
Chris@18
|
6 use Drupal\Core\Entity\EntityInterface;
|
Chris@18
|
7 use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
Chris@18
|
8 use Drupal\Core\Http\Exception\CacheableHttpException;
|
Chris@18
|
9 use Drupal\Core\Routing\EnhancerInterface;
|
Chris@18
|
10 use Drupal\jsonapi\Routing\Routes;
|
Chris@18
|
11 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
|
Chris@18
|
12 use Symfony\Component\HttpFoundation\Request;
|
Chris@18
|
13 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
Chris@18
|
14
|
Chris@18
|
15 /**
|
Chris@18
|
16 * Loads an appropriate revision for the requested resource version.
|
Chris@18
|
17 *
|
Chris@18
|
18 * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
Chris@18
|
19 * class may change at any time and this will break any dependencies on it.
|
Chris@18
|
20 *
|
Chris@18
|
21 * @see https://www.drupal.org/project/jsonapi/issues/3032787
|
Chris@18
|
22 * @see jsonapi.api.php
|
Chris@18
|
23 */
|
Chris@18
|
24 final class ResourceVersionRouteEnhancer implements EnhancerInterface {
|
Chris@18
|
25
|
Chris@18
|
26 /**
|
Chris@18
|
27 * The route default parameter name.
|
Chris@18
|
28 *
|
Chris@18
|
29 * @var string
|
Chris@18
|
30 */
|
Chris@18
|
31 const REVISION_ID_KEY = 'revision_id';
|
Chris@18
|
32
|
Chris@18
|
33 /**
|
Chris@18
|
34 * The query parameter for providing a version (revision) value.
|
Chris@18
|
35 *
|
Chris@18
|
36 * @var string
|
Chris@18
|
37 */
|
Chris@18
|
38 const RESOURCE_VERSION_QUERY_PARAMETER = 'resourceVersion';
|
Chris@18
|
39
|
Chris@18
|
40 /**
|
Chris@18
|
41 * A route parameter key which indicates that working copies were requested.
|
Chris@18
|
42 *
|
Chris@18
|
43 * @var string
|
Chris@18
|
44 */
|
Chris@18
|
45 const WORKING_COPIES_REQUESTED = 'working_copies_requested';
|
Chris@18
|
46
|
Chris@18
|
47 /**
|
Chris@18
|
48 * The cache context by which vary the loaded entity revision.
|
Chris@18
|
49 *
|
Chris@18
|
50 * @var string
|
Chris@18
|
51 *
|
Chris@18
|
52 * @todo When D8 requires PHP >=5.6, convert to expression using the RESOURCE_VERSION_QUERY_PARAMETER constant.
|
Chris@18
|
53 */
|
Chris@18
|
54 const CACHE_CONTEXT = 'url.query_args:resourceVersion';
|
Chris@18
|
55
|
Chris@18
|
56 /**
|
Chris@18
|
57 * Resource version validation regex.
|
Chris@18
|
58 *
|
Chris@18
|
59 * @var string
|
Chris@18
|
60 *
|
Chris@18
|
61 * @todo When D8 requires PHP >=5.6, convert to expression using the VersionNegotiator::SEPARATOR constant.
|
Chris@18
|
62 */
|
Chris@18
|
63 const VERSION_IDENTIFIER_VALIDATOR = '/^[a-z]+[a-z_]*[a-z]+:[a-zA-Z0-9\-]+(:[a-zA-Z0-9\-]+)*$/';
|
Chris@18
|
64
|
Chris@18
|
65 /**
|
Chris@18
|
66 * The revision ID negotiator.
|
Chris@18
|
67 *
|
Chris@18
|
68 * @var \Drupal\jsonapi\Revisions\VersionNegotiator
|
Chris@18
|
69 */
|
Chris@18
|
70 protected $versionNegotiator;
|
Chris@18
|
71
|
Chris@18
|
72 /**
|
Chris@18
|
73 * ResourceVersionRouteEnhancer constructor.
|
Chris@18
|
74 *
|
Chris@18
|
75 * @param \Drupal\jsonapi\Revisions\VersionNegotiator $version_negotiator_manager
|
Chris@18
|
76 * The version negotiator.
|
Chris@18
|
77 */
|
Chris@18
|
78 public function __construct(VersionNegotiator $version_negotiator_manager) {
|
Chris@18
|
79 $this->versionNegotiator = $version_negotiator_manager;
|
Chris@18
|
80 }
|
Chris@18
|
81
|
Chris@18
|
82 /**
|
Chris@18
|
83 * {@inheritdoc}
|
Chris@18
|
84 */
|
Chris@18
|
85 public function enhance(array $defaults, Request $request) {
|
Chris@18
|
86 if (!Routes::isJsonApiRequest($defaults) || !($resource_type = Routes::getResourceTypeNameFromParameters($defaults))) {
|
Chris@18
|
87 return $defaults;
|
Chris@18
|
88 }
|
Chris@18
|
89
|
Chris@18
|
90 $has_version_param = $request->query->has(static::RESOURCE_VERSION_QUERY_PARAMETER);
|
Chris@18
|
91
|
Chris@18
|
92 // If the resource type is not versionable, then nothing needs to be
|
Chris@18
|
93 // enhanced.
|
Chris@18
|
94 if (!$resource_type->isVersionable()) {
|
Chris@18
|
95 // If the query parameter was provided but the resource type is not
|
Chris@18
|
96 // versionable, provide a helpful error.
|
Chris@18
|
97 if ($has_version_param) {
|
Chris@18
|
98 // Until Drupal core has a generic revision access API, it is only safe
|
Chris@18
|
99 // to support the `node` and `media` entity types because they are the
|
Chris@18
|
100 // only // entity types that have revision access checks for forward
|
Chris@18
|
101 // revisions that are not the default and not the latest revision.
|
Chris@18
|
102 $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
|
Chris@18
|
103 /* Uncomment the next line and remove the following one when https://www.drupal.org/project/drupal/issues/3002352 lands in core. */
|
Chris@18
|
104 /* throw new CacheableHttpException($cacheability, 501, 'Resource versioning is not yet supported for this resource type.'); */
|
Chris@18
|
105 $message = 'JSON:API does not yet support resource versioning for this resource type.';
|
Chris@18
|
106 $message .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
|
Chris@18
|
107 $message .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
|
Chris@18
|
108 throw new CacheableHttpException($cacheability, 501, $message, NULL, []);
|
Chris@18
|
109 }
|
Chris@18
|
110 return $defaults;
|
Chris@18
|
111 }
|
Chris@18
|
112
|
Chris@18
|
113 // Since the resource type is versionable, responses must always vary by the
|
Chris@18
|
114 // requested version, without regard for whether a version query parameter
|
Chris@18
|
115 // was provided or not.
|
Chris@18
|
116 if (isset($defaults['entity'])) {
|
Chris@18
|
117 assert($defaults['entity'] instanceof EntityInterface);
|
Chris@18
|
118 $defaults['entity']->addCacheContexts([static::CACHE_CONTEXT]);
|
Chris@18
|
119 }
|
Chris@18
|
120
|
Chris@18
|
121 // If no version was specified, nothing is left to enhance.
|
Chris@18
|
122 if (!$has_version_param) {
|
Chris@18
|
123 return $defaults;
|
Chris@18
|
124 }
|
Chris@18
|
125
|
Chris@18
|
126 // Provide a helpful error when a version is specified with an unsafe
|
Chris@18
|
127 // method.
|
Chris@18
|
128 if (!$request->isMethodCacheable()) {
|
Chris@18
|
129 throw new BadRequestHttpException(sprintf('%s requests with a `%s` query parameter are not supported.', $request->getMethod(), static::RESOURCE_VERSION_QUERY_PARAMETER));
|
Chris@18
|
130 }
|
Chris@18
|
131
|
Chris@18
|
132 $resource_version_identifier = $request->query->get(static::RESOURCE_VERSION_QUERY_PARAMETER);
|
Chris@18
|
133
|
Chris@18
|
134 if (!static::isValidVersionIdentifier($resource_version_identifier)) {
|
Chris@18
|
135 $cacheability = (new CacheableMetadata())->addCacheContexts([static::CACHE_CONTEXT]);
|
Chris@18
|
136 $message = sprintf('A resource version identifier was provided in an invalid format: `%s`', $resource_version_identifier);
|
Chris@18
|
137 throw new CacheableBadRequestHttpException($cacheability, $message);
|
Chris@18
|
138 }
|
Chris@18
|
139
|
Chris@18
|
140 // Determine if the request is for a collection resource.
|
Chris@18
|
141 if ($defaults[RouteObjectInterface::CONTROLLER_NAME] === Routes::CONTROLLER_SERVICE_NAME . ':getCollection') {
|
Chris@18
|
142 $latest_version_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'latest-version';
|
Chris@18
|
143 $working_copy_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'working-copy';
|
Chris@18
|
144 // Until Drupal core has a revision access API that works on entity
|
Chris@18
|
145 // queries, filtering is not permitted on non-default revisions.
|
Chris@18
|
146 if ($request->query->has('filter') && $resource_version_identifier !== $latest_version_identifier) {
|
Chris@18
|
147 $cache_contexts = [
|
Chris@18
|
148 'url.path',
|
Chris@18
|
149 static::CACHE_CONTEXT,
|
Chris@18
|
150 'url.query_args:filter',
|
Chris@18
|
151 ];
|
Chris@18
|
152 $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts);
|
Chris@18
|
153 $message = 'JSON:API does not support filtering on revisions other than the latest version because a secure Drupal core API does not yet exist to do so.';
|
Chris@18
|
154 throw new CacheableHttpException($cacheability, 501, $message, NULL, []);
|
Chris@18
|
155 }
|
Chris@18
|
156 // 'latest-version' and 'working-copy' are the only acceptable version
|
Chris@18
|
157 // identifiers for a collection resource.
|
Chris@18
|
158 if (!in_array($resource_version_identifier, [$latest_version_identifier, $working_copy_identifier])) {
|
Chris@18
|
159 $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
|
Chris@18
|
160 $message = sprintf('Collection resources only support the following resource version identifiers: %s', implode(', ', [
|
Chris@18
|
161 $latest_version_identifier,
|
Chris@18
|
162 $working_copy_identifier,
|
Chris@18
|
163 ]));
|
Chris@18
|
164 throw new CacheableBadRequestHttpException($cacheability, $message);
|
Chris@18
|
165 }
|
Chris@18
|
166 // Whether the collection to be loaded should include only working copies.
|
Chris@18
|
167 $defaults[static::WORKING_COPIES_REQUESTED] = $resource_version_identifier === $working_copy_identifier;
|
Chris@18
|
168 return $defaults;
|
Chris@18
|
169 }
|
Chris@18
|
170
|
Chris@18
|
171 /** @var \Drupal\Core\Entity\EntityInterface $entity */
|
Chris@18
|
172 $entity = $defaults['entity'];
|
Chris@18
|
173
|
Chris@18
|
174 /** @var \Drupal\jsonapi\Revisions\VersionNegotiatorInterface $negotiator */
|
Chris@18
|
175 $resolved_revision = $this->versionNegotiator->getRevision($entity, $resource_version_identifier);
|
Chris@18
|
176 // Ensure none of the original entity cacheability is lost, especially the
|
Chris@18
|
177 // query argument's cache context.
|
Chris@18
|
178 $resolved_revision->addCacheableDependency($entity);
|
Chris@18
|
179 return ['entity' => $resolved_revision] + $defaults;
|
Chris@18
|
180 }
|
Chris@18
|
181
|
Chris@18
|
182 /**
|
Chris@18
|
183 * Validates the user input.
|
Chris@18
|
184 *
|
Chris@18
|
185 * @param string $resource_version
|
Chris@18
|
186 * The requested resource version identifier.
|
Chris@18
|
187 *
|
Chris@18
|
188 * @return bool
|
Chris@18
|
189 * TRUE if the received resource version value is valid, FALSE otherwise.
|
Chris@18
|
190 */
|
Chris@18
|
191 protected static function isValidVersionIdentifier($resource_version) {
|
Chris@18
|
192 return preg_match(static::VERSION_IDENTIFIER_VALIDATOR, $resource_version) === 1;
|
Chris@18
|
193 }
|
Chris@18
|
194
|
Chris@18
|
195 }
|