From 373f01148d7e75eea3fc69fc19273b768856c624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Brala?= <49259-bbrala@users.noreply.drupalcode.org> Date: Fri, 24 May 2024 14:03:28 +0000 Subject: [PATCH] Issue #3402388 by ihor_allin, bbrala, _tarik_, gaurav_manerkar, Ankush_03, Anybody: Pages with default includes are not cached --- .../jsonapi_defaults.services.yml | 3 + .../src/Controller/EntityResource.php | 184 +++++++++++------- .../jsonapi_defaults/src/JsonapiDefaults.php | 66 +++++++ .../src/JsonapiDefaultsInterface.php | 43 ++++ .../src/JsonapiDefaultsServiceProvider.php | 13 +- .../src/QueryArgsCacheContext.php | 93 +++++++++ .../JsonApiDefaultsFunctionalTest.php | 4 + .../Kernel/Controller/EntityResourceTest.php | 1 + .../DefaultDisabledResourceConfigTest.php | 1 + 9 files changed, 332 insertions(+), 76 deletions(-) create mode 100644 modules/jsonapi_defaults/jsonapi_defaults.services.yml create mode 100644 modules/jsonapi_defaults/src/JsonapiDefaults.php create mode 100644 modules/jsonapi_defaults/src/JsonapiDefaultsInterface.php create mode 100644 modules/jsonapi_defaults/src/QueryArgsCacheContext.php diff --git a/modules/jsonapi_defaults/jsonapi_defaults.services.yml b/modules/jsonapi_defaults/jsonapi_defaults.services.yml new file mode 100644 index 0000000..fd2fff3 --- /dev/null +++ b/modules/jsonapi_defaults/jsonapi_defaults.services.yml @@ -0,0 +1,3 @@ +services: + jsonapi_defaults_includes: + class: Drupal\jsonapi_defaults\JsonapiDefaults diff --git a/modules/jsonapi_defaults/src/Controller/EntityResource.php b/modules/jsonapi_defaults/src/Controller/EntityResource.php index cffff95..2caeb18 100644 --- a/modules/jsonapi_defaults/src/Controller/EntityResource.php +++ b/modules/jsonapi_defaults/src/Controller/EntityResource.php @@ -2,52 +2,131 @@ namespace Drupal\jsonapi_defaults\Controller; -use Drupal\Component\Serialization\Json; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Logger\LoggerChannelFactoryInterface; +use Drupal\Core\Logger\LoggerChannelInterface; +use Drupal\Core\Render\RendererInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\jsonapi\Access\EntityAccessChecker; +use Drupal\jsonapi\Context\FieldResolver; use Drupal\jsonapi\Controller\EntityResource as JsonApiEntityResourse; +use Drupal\jsonapi\IncludeResolver; use Drupal\jsonapi\Query\OffsetPage; use Drupal\jsonapi\Query\Sort; use Drupal\jsonapi\ResourceType\ResourceType; -use Drupal\jsonapi\Routing\Routes; +use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface; +use Drupal\jsonapi_defaults\JsonapiDefaultsInterface; use Drupal\jsonapi_extras\Entity\JsonapiResourceConfig; -use Drupal\jsonapi_extras\ResourceType\ConfigurableResourceType; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Serializer\SerializerInterface; /** * Overrides jsonapi module EntityResource controller. */ class EntityResource extends JsonApiEntityResourse { + /** + * The jsonapi defaults service. + * + * @var \Drupal\jsonapi_defaults\JsonapiDefaultsInterface + */ + protected JsonapiDefaultsInterface $jsonapiDefaults; + + /** + * The logger service. + * + * @var \Drupal\Core\Logger\LoggerChannelInterface + */ + protected LoggerChannelInterface $logger; + + /** + * Instantiates an EntityResource object. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager + * The entity type field manager. + * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository + * The JSON:API resource type repository. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer. + * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository + * The entity repository. + * @param \Drupal\jsonapi\IncludeResolver $include_resolver + * The include resolver. + * @param \Drupal\jsonapi\Access\EntityAccessChecker $entity_access_checker + * The JSON:API entity access checker. + * @param \Drupal\jsonapi\Context\FieldResolver $field_resolver + * The JSON:API field resolver. + * @param \Symfony\Component\Serializer\SerializerInterface $serializer + * The JSON:API serializer. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Drupal\Core\Session\AccountInterface $user + * The current user account. + * @param \Drupal\jsonapi_defaults\JsonapiDefaultsInterface $jsonapi_defaults + * The jsonapi default service. + * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $loggerFactory + * The logger service. + */ + public function __construct( + EntityTypeManagerInterface $entity_type_manager, + EntityFieldManagerInterface $field_manager, + ResourceTypeRepositoryInterface $resource_type_repository, + RendererInterface $renderer, + EntityRepositoryInterface $entity_repository, + IncludeResolver $include_resolver, + EntityAccessChecker $entity_access_checker, + FieldResolver $field_resolver, + SerializerInterface $serializer, + TimeInterface $time, + AccountInterface $user, + JsonapiDefaultsInterface $jsonapi_defaults, + LoggerChannelFactoryInterface $loggerFactory + ) { + parent::__construct( + $entity_type_manager, + $field_manager, + $resource_type_repository, + $renderer, + $entity_repository, + $include_resolver, + $entity_access_checker, + $field_resolver, + $serializer, + $time, + $user, + ); + $this->jsonapiDefaults = $jsonapi_defaults; + $this->logger = $loggerFactory->get('jsonapi_defaults'); + } + /** * {@inheritdoc} */ protected function getJsonApiParams(Request $request, ResourceType $resource_type) { - // If this is a related resource, then we need to swap to the new resource - // type. - $related_field = $request->attributes->get('_on_relationship') - ? NULL - : $request->attributes->get('related'); try { - $resource_type = static::correctResourceTypeOnRelated($related_field, $resource_type); + $resourceConfig = $this->jsonapiDefaults->getResourceConfigFromRequest($request); } catch (\LengthException $e) { - watchdog_exception('jsonapi_defaults', $e); - $resource_type = NULL; + $this->logger->error($e); + $resourceConfig = NULL; } - if (!$resource_type instanceof ConfigurableResourceType) { - return parent::getJsonApiParams($request, $resource_type); - } - $resource_config = $resource_type->getJsonapiResourceConfig(); - if (!$resource_config instanceof JsonapiResourceConfig) { + if (!$resourceConfig instanceof JsonapiResourceConfig) { return parent::getJsonApiParams($request, $resource_type); } - $default_filter_input = $resource_config->getThirdPartySetting( + + $default_filter_input = $resourceConfig->getThirdPartySetting( 'jsonapi_defaults', 'default_filter', [] ); - $default_sorting_input = $resource_config->getThirdPartySetting( + $default_sorting_input = $resourceConfig->getThirdPartySetting( 'jsonapi_defaults', 'default_sorting', [] @@ -93,7 +172,7 @@ class EntityResource extends JsonApiEntityResourse { // Implements overridden page limits. $params = parent::getJsonApiParams($request, $resource_type); - $this->setPageLimit($request, $resource_config, $params); + $this->setPageLimit($request, $resourceConfig, $params); return $params; } @@ -101,35 +180,27 @@ class EntityResource extends JsonApiEntityResourse { * {@inheritdoc} */ public function getIncludes(Request $request, $data) { - if ( - ($resource_type = $request->get(Routes::RESOURCE_TYPE_KEY)) - && $resource_type instanceof ConfigurableResourceType - && !$request->get('_on_relationship') - ) { + if (!$request->get('_on_relationship')) { try { - $resource_type = static::correctResourceTypeOnRelated($request->get('related'), $resource_type); + $resourceConfig = $this->jsonapiDefaults->getResourceConfigFromRequest($request); } catch (\LengthException $e) { - watchdog_exception('jsonapi_defaults', $e); - return parent::getIncludes($request, $data); - } - if (!$resource_type instanceof ConfigurableResourceType) { - return parent::getIncludes($request, $data); + $this->logger->error($e); + $resourceConfig = NULL; } - $resource_config = $resource_type->getJsonapiResourceConfig(); - if (!$resource_config instanceof JsonapiResourceConfig) { + + if (!$resourceConfig) { return parent::getIncludes($request, $data); } - $default_includes = $resource_config->getThirdPartySetting( + + $defaultIncludes = $resourceConfig->getThirdPartySetting( 'jsonapi_defaults', 'default_include', [] ); - if (!empty($default_includes) && $request->query->get('include') === NULL) { - $includes = array_unique(array_filter(array_merge( - $default_includes, - explode(',', $request->query->get('include', '')) - ))); + + if (!empty($defaultIncludes) && $request->query->get('include') === NULL) { + $includes = array_unique(array_filter(array_merge($defaultIncludes))); $request->query->set('include', implode(',', $includes)); } } @@ -137,43 +208,6 @@ class EntityResource extends JsonApiEntityResourse { return parent::getIncludes($request, $data); } - /** - * Returns the correct resource type when operating on related fields. - * - * @param string $related_field - * The name of the related field to use. NULL if not using a related field. - * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type - * The resource type straight from the request. - * - * @return \Drupal\jsonapi\ResourceType\ResourceType - * The resource type to use to load the includes. - * - * @throws \LengthException - * If there is more than one relatable resource type. - */ - public static function correctResourceTypeOnRelated($related_field, ResourceType $resource_type) { - if (!$related_field) { - return $resource_type; - } - $relatable_resource_types = $resource_type - ->getRelatableResourceTypesByField($related_field); - if (count($relatable_resource_types) > 1) { - $message = sprintf( - '%s -- %s', - 'Impossible to apply defaults on a related resource with heterogeneous resource types.', - Json::encode([ - 'related_field' => $related_field, - 'host_resource_type' => $resource_type->getPath(), - 'target_resource_types' => array_map(function (ResourceType $resource_type) { - return $resource_type->getPath(); - }, $relatable_resource_types), - ]) - ); - throw new \LengthException($message); - } - return $relatable_resource_types[0] ?? NULL; - } - /** * Set filter into nested array. * diff --git a/modules/jsonapi_defaults/src/JsonapiDefaults.php b/modules/jsonapi_defaults/src/JsonapiDefaults.php new file mode 100644 index 0000000..491c147 --- /dev/null +++ b/modules/jsonapi_defaults/src/JsonapiDefaults.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\jsonapi_defaults; + +use Drupal\Component\Serialization\Json; +use Drupal\jsonapi\ResourceType\ResourceType; +use Drupal\jsonapi\Routing\Routes; +use Drupal\jsonapi_extras\Entity\JsonapiResourceConfig; +use Drupal\jsonapi_extras\ResourceType\ConfigurableResourceType; +use Symfony\Component\HttpFoundation\Request; + +/** + * Provides reusable methods and features. + */ +class JsonapiDefaults implements JsonapiDefaultsInterface { + + /** + * {@inheritdoc} + */ + public function getResourceConfigFromRequest(Request $request, ResourceType $resourceType = NULL): ?JsonapiResourceConfig { + $resourceType = !$resourceType ? $request->get(Routes::RESOURCE_TYPE_KEY) : $resourceType; + + if ($resourceType instanceof ConfigurableResourceType) { + $relatedField = $request->attributes->get('_on_relationship') + ? NULL + : $request->attributes->get('related'); + $resourceType = static::correctResourceTypeOnRelated($relatedField, $resourceType); + + if ( + $resourceType instanceof ConfigurableResourceType + && ($resourceConfig = $resourceType->getJsonapiResourceConfig()) instanceof JsonapiResourceConfig + ) { + return $resourceConfig; + } + } + + return NULL; + } + + /** + * {@inheritdoc} + */ + public static function correctResourceTypeOnRelated(?string $related_field, ResourceType $resource_type): ?ResourceType { + if (!$related_field) { + return $resource_type; + } + $relatable_resource_types = $resource_type + ->getRelatableResourceTypesByField($related_field); + if (count($relatable_resource_types) > 1) { + $message = sprintf( + '%s -- %s', + 'Impossible to apply defaults on a related resource with heterogeneous resource types.', + Json::encode([ + 'related_field' => $related_field, + 'host_resource_type' => $resource_type->getPath(), + 'target_resource_types' => array_map(function (ResourceType $resource_type) { + return $resource_type->getPath(); + }, $relatable_resource_types), + ]) + ); + throw new \LengthException($message); + } + return $relatable_resource_types[0] ?? NULL; + } + +} diff --git a/modules/jsonapi_defaults/src/JsonapiDefaultsInterface.php b/modules/jsonapi_defaults/src/JsonapiDefaultsInterface.php new file mode 100644 index 0000000..0b27d24 --- /dev/null +++ b/modules/jsonapi_defaults/src/JsonapiDefaultsInterface.php @@ -0,0 +1,43 @@ +<?php + +namespace Drupal\jsonapi_defaults; + +use Drupal\jsonapi\ResourceType\ResourceType; +use Drupal\jsonapi_extras\Entity\JsonapiResourceConfig; +use Symfony\Component\HttpFoundation\Request; + +/** + * Defines interface for the JsonapiDefaults service. + */ +interface JsonapiDefaultsInterface { + + /** + * Get the jsonapi_resource_config from the request. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * @param \Symfony\Component\HttpFoundation\ResourceType|null $resourceType + * The JSON:API resource type. + * + * @return \Drupal\jsonapi_extras\Entity\JsonapiResourceConfig|null + * The jsonapi_resource_config entity or NULL. + */ + public function getResourceConfigFromRequest(Request $request, ResourceType $resourceType = NULL): ?JsonapiResourceConfig; + + /** + * Returns the correct resource type when operating on related fields. + * + * @param string|null $related_field + * The name of the related field to use. NULL if not using a related field. + * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type + * The resource type straight from the request. + * + * @return \Drupal\jsonapi\ResourceType\ResourceType + * The resource type to use to load the includes. + * + * @throws \LengthException + * If there is more than one relatable resource type. + */ + public static function correctResourceTypeOnRelated(?string $related_field, ResourceType $resource_type): ?ResourceType; + +} diff --git a/modules/jsonapi_defaults/src/JsonapiDefaultsServiceProvider.php b/modules/jsonapi_defaults/src/JsonapiDefaultsServiceProvider.php index cf60b55..10a757d 100644 --- a/modules/jsonapi_defaults/src/JsonapiDefaultsServiceProvider.php +++ b/modules/jsonapi_defaults/src/JsonapiDefaultsServiceProvider.php @@ -4,6 +4,7 @@ namespace Drupal\jsonapi_defaults; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Symfony\Component\DependencyInjection\Reference; /** * Modifies the jsonapi normalizer service. @@ -18,7 +19,17 @@ class JsonapiDefaultsServiceProvider extends ServiceProviderBase { if ($container->hasDefinition('jsonapi.entity_resource')) { /** @var \Symfony\Component\DependencyInjection\Definition $definition */ $definition = $container->getDefinition('jsonapi.entity_resource'); - $definition->setClass('Drupal\jsonapi_defaults\Controller\EntityResource'); + $definition->setClass('Drupal\jsonapi_defaults\Controller\EntityResource') + ->addArgument(new Reference('jsonapi_defaults_includes')) + ->addArgument(new Reference('logger.factory')); + } + + if ($container->has('cache_context.url.query_args')) { + // Override the cache_context.url.query_args service. + /** @var \Symfony\Component\DependencyInjection\Definition $definition */ + $definition = $container->getDefinition('cache_context.url.query_args'); + $definition->setClass(QueryArgsCacheContext::class) + ->addArgument(new Reference('jsonapi_defaults_includes')); } } diff --git a/modules/jsonapi_defaults/src/QueryArgsCacheContext.php b/modules/jsonapi_defaults/src/QueryArgsCacheContext.php new file mode 100644 index 0000000..e567cc3 --- /dev/null +++ b/modules/jsonapi_defaults/src/QueryArgsCacheContext.php @@ -0,0 +1,93 @@ +<?php + +namespace Drupal\jsonapi_defaults; + +use Drupal\Core\Cache\Context\QueryArgsCacheContext as CoreQueryArgsCacheContext; +use Drupal\jsonapi_extras\Entity\JsonapiResourceConfig; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Override core QueryArgsCacheContext service. + * + * To ensure that calls to JSON API resources with default includes are + * cacheable, it is necessary to ignore all default includes arguments. + * This is because they are set in the + * 'Drupal\jsonapi_defaults\Controller\EntityResource::getIncludes' to the + * request dynamically before the cache is set. On the other hand, if the cache + * is stored in the database, 'getIncludes' is executed after Drupal attempts to + * retrieve the cache. This results in a situation where we always receive an + * uncached response due to a mismatch with include arguments. + */ +class QueryArgsCacheContext extends CoreQueryArgsCacheContext { + + /** + * The jsonapi defaults service. + * + * @var \Drupal\jsonapi_defaults\JsonapiDefaultsInterface + */ + protected JsonapiDefaultsInterface $jsonapiDefaults; + + /** + * Constructs a new RequestStackCacheContextBase class. + * + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. + * @param \Drupal\jsonapi_defaults\JsonapiDefaultsInterface $jsonapi_defaults + * The jsonapi default service. + */ + public function __construct(RequestStack $request_stack, JsonapiDefaultsInterface $jsonapi_defaults) { + parent::__construct($request_stack); + $this->jsonapiDefaults = $jsonapi_defaults; + } + + /** + * {@inheritdoc} + */ + public function getContext($query_arg = NULL) { + // Ignore context if request contains default includes arguments. + if ($query_arg == 'include' && $this->hasOnlyDefaultIncludes()) { + return ''; + } + + return parent::getContext($query_arg); + } + + /** + * Check if the request contains only default includes. + * + * @return bool + * TRUE if the request contains only default includes, FALSE otherwise.. + */ + protected function hasOnlyDefaultIncludes(): bool { + try { + $resourceConfig = $this->jsonapiDefaults->getResourceConfigFromRequest( + $this->requestStack->getCurrentRequest() + ); + } + catch (\LengthException $e) { + return FALSE; + } + + if (!$resourceConfig instanceof JsonapiResourceConfig) { + return FALSE; + } + + $default_includes = $resourceConfig->getThirdPartySetting( + 'jsonapi_defaults', + 'default_include', + [] + ); + + $current_request = $this->requestStack->getCurrentRequest(); + $current_includes = $current_request->query->get('include'); + + if ($current_includes) { + $current_includes = explode(',', (string) $current_includes); + return empty(array_diff($current_includes, $default_includes)); + } + else { + return !empty($default_includes); + } + } + +} diff --git a/modules/jsonapi_defaults/tests/src/Functional/JsonApiDefaultsFunctionalTest.php b/modules/jsonapi_defaults/tests/src/Functional/JsonApiDefaultsFunctionalTest.php index 1069580..b5f0f64 100644 --- a/modules/jsonapi_defaults/tests/src/Functional/JsonApiDefaultsFunctionalTest.php +++ b/modules/jsonapi_defaults/tests/src/Functional/JsonApiDefaultsFunctionalTest.php @@ -68,12 +68,16 @@ class JsonApiDefaultsFunctionalTest extends JsonApiExtrasFunctionalTestBase { // 1. Apply default filters and includes on a resource and a related // resource. $response = $this->drupalGet('/api/articles'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS'); $parsed_response = Json::decode($response); $this->assertArrayHasKey('data', $parsed_response); $this->assertCount(1, $parsed_response['data']); $this->assertEquals(3, $parsed_response['data'][0]['attributes']['internalId']); $this->assertArrayHasKey('included', $parsed_response); $this->assertGreaterThan(0, count($parsed_response['included'])); + // Make sure that after the second request, we have a cached response. + $this->drupalGet('/api/articles'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT'); // Make sure related resources don't fail. $response = $this->drupalGet('/api/articles/' . $this->nodes[0]->uuid() . '/owner'); $parsed_response = Json::decode($response); diff --git a/tests/src/Kernel/Controller/EntityResourceTest.php b/tests/src/Kernel/Controller/EntityResourceTest.php index f52ea43..69513f4 100644 --- a/tests/src/Kernel/Controller/EntityResourceTest.php +++ b/tests/src/Kernel/Controller/EntityResourceTest.php @@ -30,6 +30,7 @@ class EntityResourceTest extends KernelTestBase { * {@inheritdoc} */ protected static $modules = [ + 'file', 'node', 'jsonapi', 'serialization', diff --git a/tests/src/Kernel/DefaultDisabledResourceConfigTest.php b/tests/src/Kernel/DefaultDisabledResourceConfigTest.php index d86fba5..324b1c2 100644 --- a/tests/src/Kernel/DefaultDisabledResourceConfigTest.php +++ b/tests/src/Kernel/DefaultDisabledResourceConfigTest.php @@ -20,6 +20,7 @@ class DefaultDisabledResourceConfigTest extends KernelTestBase { * {@inheritdoc} */ protected static $modules = [ + 'file', 'jsonapi', 'jsonapi_extras', 'entity_test', -- GitLab