annotate core/modules/jsonapi/src/Revisions/ResourceVersionRouteEnhancer.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\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 }