Chris@18: =5.6, convert to expression using the RESOURCE_VERSION_QUERY_PARAMETER constant. Chris@18: */ Chris@18: const CACHE_CONTEXT = 'url.query_args:resourceVersion'; Chris@18: Chris@18: /** Chris@18: * Resource version validation regex. Chris@18: * Chris@18: * @var string Chris@18: * Chris@18: * @todo When D8 requires PHP >=5.6, convert to expression using the VersionNegotiator::SEPARATOR constant. Chris@18: */ Chris@18: const VERSION_IDENTIFIER_VALIDATOR = '/^[a-z]+[a-z_]*[a-z]+:[a-zA-Z0-9\-]+(:[a-zA-Z0-9\-]+)*$/'; Chris@18: Chris@18: /** Chris@18: * The revision ID negotiator. Chris@18: * Chris@18: * @var \Drupal\jsonapi\Revisions\VersionNegotiator Chris@18: */ Chris@18: protected $versionNegotiator; Chris@18: Chris@18: /** Chris@18: * ResourceVersionRouteEnhancer constructor. Chris@18: * Chris@18: * @param \Drupal\jsonapi\Revisions\VersionNegotiator $version_negotiator_manager Chris@18: * The version negotiator. Chris@18: */ Chris@18: public function __construct(VersionNegotiator $version_negotiator_manager) { Chris@18: $this->versionNegotiator = $version_negotiator_manager; Chris@18: } Chris@18: Chris@18: /** Chris@18: * {@inheritdoc} Chris@18: */ Chris@18: public function enhance(array $defaults, Request $request) { Chris@18: if (!Routes::isJsonApiRequest($defaults) || !($resource_type = Routes::getResourceTypeNameFromParameters($defaults))) { Chris@18: return $defaults; Chris@18: } Chris@18: Chris@18: $has_version_param = $request->query->has(static::RESOURCE_VERSION_QUERY_PARAMETER); Chris@18: Chris@18: // If the resource type is not versionable, then nothing needs to be Chris@18: // enhanced. Chris@18: if (!$resource_type->isVersionable()) { Chris@18: // If the query parameter was provided but the resource type is not Chris@18: // versionable, provide a helpful error. Chris@18: if ($has_version_param) { Chris@18: // Until Drupal core has a generic revision access API, it is only safe Chris@18: // to support the `node` and `media` entity types because they are the Chris@18: // only // entity types that have revision access checks for forward Chris@18: // revisions that are not the default and not the latest revision. Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]); Chris@18: /* Uncomment the next line and remove the following one when https://www.drupal.org/project/drupal/issues/3002352 lands in core. */ Chris@18: /* throw new CacheableHttpException($cacheability, 501, 'Resource versioning is not yet supported for this resource type.'); */ Chris@18: $message = 'JSON:API does not yet support resource versioning for this resource type.'; Chris@18: $message .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.'; Chris@18: $message .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.'; Chris@18: throw new CacheableHttpException($cacheability, 501, $message, NULL, []); Chris@18: } Chris@18: return $defaults; Chris@18: } Chris@18: Chris@18: // Since the resource type is versionable, responses must always vary by the Chris@18: // requested version, without regard for whether a version query parameter Chris@18: // was provided or not. Chris@18: if (isset($defaults['entity'])) { Chris@18: assert($defaults['entity'] instanceof EntityInterface); Chris@18: $defaults['entity']->addCacheContexts([static::CACHE_CONTEXT]); Chris@18: } Chris@18: Chris@18: // If no version was specified, nothing is left to enhance. Chris@18: if (!$has_version_param) { Chris@18: return $defaults; Chris@18: } Chris@18: Chris@18: // Provide a helpful error when a version is specified with an unsafe Chris@18: // method. Chris@18: if (!$request->isMethodCacheable()) { Chris@18: throw new BadRequestHttpException(sprintf('%s requests with a `%s` query parameter are not supported.', $request->getMethod(), static::RESOURCE_VERSION_QUERY_PARAMETER)); Chris@18: } Chris@18: Chris@18: $resource_version_identifier = $request->query->get(static::RESOURCE_VERSION_QUERY_PARAMETER); Chris@18: Chris@18: if (!static::isValidVersionIdentifier($resource_version_identifier)) { Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts([static::CACHE_CONTEXT]); Chris@18: $message = sprintf('A resource version identifier was provided in an invalid format: `%s`', $resource_version_identifier); Chris@18: throw new CacheableBadRequestHttpException($cacheability, $message); Chris@18: } Chris@18: Chris@18: // Determine if the request is for a collection resource. Chris@18: if ($defaults[RouteObjectInterface::CONTROLLER_NAME] === Routes::CONTROLLER_SERVICE_NAME . ':getCollection') { Chris@18: $latest_version_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'latest-version'; Chris@18: $working_copy_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'working-copy'; Chris@18: // Until Drupal core has a revision access API that works on entity Chris@18: // queries, filtering is not permitted on non-default revisions. Chris@18: if ($request->query->has('filter') && $resource_version_identifier !== $latest_version_identifier) { Chris@18: $cache_contexts = [ Chris@18: 'url.path', Chris@18: static::CACHE_CONTEXT, Chris@18: 'url.query_args:filter', Chris@18: ]; Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts); Chris@18: $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: throw new CacheableHttpException($cacheability, 501, $message, NULL, []); Chris@18: } Chris@18: // 'latest-version' and 'working-copy' are the only acceptable version Chris@18: // identifiers for a collection resource. Chris@18: if (!in_array($resource_version_identifier, [$latest_version_identifier, $working_copy_identifier])) { Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]); Chris@18: $message = sprintf('Collection resources only support the following resource version identifiers: %s', implode(', ', [ Chris@18: $latest_version_identifier, Chris@18: $working_copy_identifier, Chris@18: ])); Chris@18: throw new CacheableBadRequestHttpException($cacheability, $message); Chris@18: } Chris@18: // Whether the collection to be loaded should include only working copies. Chris@18: $defaults[static::WORKING_COPIES_REQUESTED] = $resource_version_identifier === $working_copy_identifier; Chris@18: return $defaults; Chris@18: } Chris@18: Chris@18: /** @var \Drupal\Core\Entity\EntityInterface $entity */ Chris@18: $entity = $defaults['entity']; Chris@18: Chris@18: /** @var \Drupal\jsonapi\Revisions\VersionNegotiatorInterface $negotiator */ Chris@18: $resolved_revision = $this->versionNegotiator->getRevision($entity, $resource_version_identifier); Chris@18: // Ensure none of the original entity cacheability is lost, especially the Chris@18: // query argument's cache context. Chris@18: $resolved_revision->addCacheableDependency($entity); Chris@18: return ['entity' => $resolved_revision] + $defaults; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Validates the user input. Chris@18: * Chris@18: * @param string $resource_version Chris@18: * The requested resource version identifier. Chris@18: * Chris@18: * @return bool Chris@18: * TRUE if the received resource version value is valid, FALSE otherwise. Chris@18: */ Chris@18: protected static function isValidVersionIdentifier($resource_version) { Chris@18: return preg_match(static::VERSION_IDENTIFIER_VALIDATOR, $resource_version) === 1; Chris@18: } Chris@18: Chris@18: }