Unverified Commit 6033d397 authored by larowlan's avatar larowlan

Issue #2819335 by Wim Leers, e0ipso, gabesullice, jibran,...

Issue #2819335 by Wim Leers, e0ipso, gabesullice, jibran, kristiaanvandeneynde, itsekhmistro, steven.wichers, ndobromirov, larowlan, btully, kim.pepper, yogeshmpawar: Resource (entity) normalization should use partial caching
parent 621b626a
......@@ -44,8 +44,15 @@ services:
- { name: jsonapi_normalizer }
serializer.normalizer.resource_object.jsonapi:
class: Drupal\jsonapi\Normalizer\ResourceObjectNormalizer
arguments: ['@jsonapi.normalization_cacher']
tags:
- { name: jsonapi_normalizer }
jsonapi.normalization_cacher:
class: Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher
calls:
- ['setRenderCache', ['@render_cache']]
tags:
- { name: event_subscriber }
serializer.normalizer.content_entity.jsonapi:
class: Drupal\jsonapi\Normalizer\ContentEntityDenormalizer
arguments: ['@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
......@@ -118,6 +125,12 @@ services:
# We need this to add this to the Drupal's cache_tags.invalidator service.
# This way it can invalidate the data in here based on tags.
tags: [{ name: cache.bin }]
cache.jsonapi_normalizations:
class: Drupal\Core\Cache\CacheBackendInterface
tags:
- { name: cache.bin }
factory: cache_factory:get
arguments: [jsonapi_normalizations]
# Route filter.
jsonapi.route_filter.format_setter:
......
<?php
namespace Drupal\jsonapi\EventSubscriber;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Render\RenderCacheInterface;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\PostResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Caches entity normalizations after the response has been sent.
*
* @internal
* @see \Drupal\jsonapi\Normalizer\ResourceObjectNormalizer::getNormalization()
* @todo Refactor once https://www.drupal.org/node/2551419 lands.
*/
class ResourceObjectNormalizationCacher implements EventSubscriberInterface {
/**
* Key for the base subset.
*
* The base subset contains the parts of the normalization that are always
* present. The presence or absence of these are not affected by the requested
* sparse field sets. This typically includes the resource type name, and the
* resource ID.
*/
const RESOURCE_CACHE_SUBSET_BASE = 'base';
/**
* Key for the fields subset.
*
* The fields subset contains the parts of the normalization that can appear
* in a normalization based on the selected field set. This subset is
* incrementally built across different requests for the same resource object.
* A given field is normalized and put into the cache whenever there is a
* cache miss for that field.
*/
const RESOURCE_CACHE_SUBSET_FIELDS = 'fields';
/**
* The render cache.
*
* @var \Drupal\Core\Render\RenderCacheInterface
*/
protected $renderCache;
/**
* The things to cache after the response has been sent.
*
* @var array
*/
protected $toCache = [];
/**
* Sets the render cache service.
*
* @param \Drupal\Core\Render\RenderCacheInterface $render_cache
* The render cache.
*/
public function setRenderCache(RenderCacheInterface $render_cache) {
$this->renderCache = $render_cache;
}
/**
* Reads an entity normalization from cache.
*
* The returned normalization may only be a partial normalization because it
* was previously normalized with a sparse fieldset.
*
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
* The resource object for which to generate a cache item.
*
* @return array|false
* The cached normalization parts, or FALSE if not yet cached.
*
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::renderArrayToResponse()
*/
public function get(ResourceObject $object) {
$cached = $this->renderCache->get(static::generateLookupRenderArray($object));
return $cached ? $cached['#data'] : FALSE;
}
/**
* Adds a normalization to be cached after the response has been sent.
*
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
* The resource object for which to generate a cache item.
* @param array $normalization_parts
* The normalization parts to cache.
*/
public function saveOnTerminate(ResourceObject $object, array $normalization_parts) {
assert(
array_keys($normalization_parts) === [
static::RESOURCE_CACHE_SUBSET_BASE,
static::RESOURCE_CACHE_SUBSET_FIELDS,
]
);
$resource_type = $object->getResourceType();
$key = $resource_type->getTypeName() . ':' . $object->getId();
$this->toCache[$key] = [$object, $normalization_parts];
}
/**
* Writes normalizations of entities to cache, if any were created.
*
* @param \Symfony\Component\HttpKernel\Event\PostResponseEvent $event
* The Event to process.
*/
public function onTerminate(PostResponseEvent $event) {
foreach ($this->toCache as $value) {
list($object, $normalization_parts) = $value;
$this->set($object, $normalization_parts);
}
}
/**
* Writes a normalization to cache.
*
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
* The resource object for which to generate a cache item.
* @param array $normalization_parts
* The normalization parts to cache.
*
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::responseToRenderArray()
* @todo Refactor/remove once https://www.drupal.org/node/2551419 lands.
*/
protected function set(ResourceObject $object, array $normalization_parts) {
$base = static::generateLookupRenderArray($object);
$data_as_render_array = $base + [
// The data we actually care about.
'#data' => $normalization_parts,
// Tell RenderCache to cache the #data property: the data we actually care
// about.
'#cache_properties' => ['#data'],
// These exist only to fulfill the requirements of the RenderCache, which
// is designed to work with render arrays only. We don't care about these.
'#markup' => '',
'#attached' => '',
];
// Merge the entity's cacheability metadata with that of the normalization
// parts, so that RenderCache can take care of cache redirects for us.
CacheableMetadata::createFromObject($object)
->merge(static::mergeCacheableDependencies($normalization_parts[static::RESOURCE_CACHE_SUBSET_FIELDS]))
->applyTo($data_as_render_array);
$this->renderCache->set($data_as_render_array, $base);
}
/**
* Generates a lookup render array for a normalization.
*
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
* The resource object for which to generate a cache item.
*
* @return array
* A render array for use with the RenderCache service.
*
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::$dynamicPageCacheRedirectRenderArray
*/
protected static function generateLookupRenderArray(ResourceObject $object) {
return [
'#cache' => [
'keys' => [$object->getResourceType()->getTypeName(), $object->getId()],
'bin' => 'jsonapi_normalizations',
],
];
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[KernelEvents::TERMINATE][] = ['onTerminate'];
return $events;
}
/**
* Determines the joint cacheability of all provided dependencies.
*
* @param \Drupal\Core\Cache\CacheableDependencyInterface|object[] $dependencies
* The dependencies.
*
* @return \Drupal\Core\Cache\CacheableMetadata
* The cacheability of all dependencies.
*
* @see \Drupal\Core\Cache\RefinableCacheableDependencyInterface::addCacheableDependency()
*/
protected static function mergeCacheableDependencies(array $dependencies) {
$merged_cacheability = new CacheableMetadata();
array_walk($dependencies, function ($dependency) use ($merged_cacheability) {
$merged_cacheability->addCacheableDependency($dependency);
});
return $merged_cacheability;
}
}
......@@ -4,6 +4,7 @@
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
......@@ -24,6 +25,23 @@ class ResourceObjectNormalizer extends NormalizerBase {
*/
protected $supportedInterfaceOrClass = ResourceObject::class;
/**
* The entity normalization cacher.
*
* @var \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher
*/
protected $cacher;
/**
* Constructs a ResourceObjectNormalizer object.
*
* @param \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher $cacher
* The entity normalization cacher.
*/
public function __construct(ResourceObjectNormalizationCacher $cacher) {
$this->cacher = $cacher;
}
/**
* {@inheritdoc}
*/
......@@ -49,23 +67,91 @@ public function normalize($object, $format = NULL, array $context = []) {
else {
$field_names = array_keys($fields);
}
$normalizer_values = [];
foreach ($fields as $field_name => $field) {
$in_sparse_fieldset = in_array($field_name, $field_names);
// Omit fields not listed in sparse fieldsets.
if (!$in_sparse_fieldset) {
continue;
}
$normalizer_values[$field_name] = $this->serializeField($field, $context, $format);
}
$normalization_parts = $this->getNormalization($field_names, $object, $format, $context);
// Keep only the requested fields (the cached normalization gradually grows
// to the complete set of fields).
$fields = $normalization_parts[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS];
$field_normalizations = array_intersect_key($fields, array_flip($field_names));
$relationship_field_names = array_keys($resource_type->getRelatableResourceTypes());
return CacheableNormalization::aggregate([
'type' => CacheableNormalization::permanent($resource_type->getTypeName()),
'id' => CacheableNormalization::permanent($object->getId()),
'attributes' => CacheableNormalization::aggregate(array_diff_key($normalizer_values, array_flip($relationship_field_names)))->omitIfEmpty(),
'relationships' => CacheableNormalization::aggregate(array_intersect_key($normalizer_values, array_flip($relationship_field_names)))->omitIfEmpty(),
'links' => $this->serializer->normalize($object->getLinks(), $format, $context)->omitIfEmpty(),
])->withCacheableDependency($object);
$attributes = array_diff_key($field_normalizations, array_flip($relationship_field_names));
$relationships = array_intersect_key($field_normalizations, array_flip($relationship_field_names));
$entity_normalization = array_filter(
$normalization_parts[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE] + [
'attributes' => CacheableNormalization::aggregate($attributes)->omitIfEmpty(),
'relationships' => CacheableNormalization::aggregate($relationships)->omitIfEmpty(),
]
);
return CacheableNormalization::aggregate($entity_normalization)->withCacheableDependency($object);
}
/**
* Normalizes an entity using the given fieldset.
*
* @param string[] $field_names
* The field names to normalize (the sparse fieldset, if any).
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
* The resource object to partially normalize.
* @param string $format
* The format in which the normalization will be encoded.
* @param array $context
* Context options for the normalizer.
*
* @return array
* An array with two key-value pairs:
* - 'base': array, the base normalization of the entity, that does not
* depend on which sparse fieldset was requested.
* - 'fields': CacheableNormalization for all requested fields.
*
* @see ::normalize()
*/
protected function getNormalization(array $field_names, ResourceObject $object, $format = NULL, array $context = []) {
$cached_normalization_parts = $this->cacher->get($object);
$normalizer_values = $cached_normalization_parts !== FALSE
? $cached_normalization_parts
: static::buildEmptyNormalization($object);
$fields = &$normalizer_values[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS];
$non_cached_fields = array_diff_key($object->getFields(), $fields);
$non_cached_requested_fields = array_intersect_key($non_cached_fields, array_flip($field_names));
foreach ($non_cached_requested_fields as $field_name => $field) {
$fields[$field_name] = $this->serializeField($field, $context, $format);
}
// Add links if missing.
$base = &$normalizer_values[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE];
$base['links'] = isset($base['links'])
? $base['links']
: $this->serializer
->normalize($object->getLinks(), $format, $context)
->omitIfEmpty();
if (!empty($non_cached_requested_fields)) {
$this->cacher->saveOnTerminate($object, $normalizer_values);
}
return $normalizer_values;
}
/**
* Builds the empty normalization structure for cache misses.
*
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
* The resource object being normalized.
*
* @return array
* The normalization structure as defined in ::getNormalization().
*
* @see ::getNormalization()
*/
protected static function buildEmptyNormalization(ResourceObject $object) {
return [
ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE => [
'type' => CacheableNormalization::permanent($object->getResourceType()->getTypeName()),
'id' => CacheableNormalization::permanent($object->getId()),
],
ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS => [],
];
}
/**
......
......@@ -142,7 +142,7 @@ public function testFormatAgnosticNormalizers($test_module, $expected_value_json
// Asserts the expected JSON:API normalization.
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute('jsonapi.entity_test--entity_test.individual', ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$client = $this->getSession()->getDriver()->getClient()->getClient();
$response = $client->request('GET', $url->setAbsolute(TRUE)->toString());
$document = Json::decode((string) $response->getBody());
......
......@@ -7,6 +7,7 @@
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
......@@ -267,7 +268,7 @@ public function testPatchPath() {
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
// GET node's current normalization.
$response = $this->request('GET', $url, $this->getAuthenticationRequestOptions());
......@@ -301,12 +302,13 @@ public function testPatchPath() {
public function testGetIndividual() {
parent::testGetIndividual();
$this->assertCacheableNormalizations();
// Unpublish node.
$this->entity->setUnpublished()->save();
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$request_options = $this->getAuthenticationRequestOptions();
// 403 when accessing own unpublished node.
......@@ -350,6 +352,65 @@ public function testGetIndividual() {
$this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $expected_cache_contexts, FALSE, 'UNCACHEABLE');
}
/**
* Asserts that normalizations are cached in an incremental way.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function assertCacheableNormalizations() {
// Save the entity to invalidate caches.
$this->entity->save();
$uuid = $this->entity->uuid();
$cache = \Drupal::service('render_cache')->get([
'#cache' => [
'keys' => ['node--camelids', $uuid],
'bin' => 'jsonapi_normalizations',
],
]);
// After saving the entity the normalization should not be cached.
$this->assertFalse($cache);
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]);
// $url = $this->entity->toUrl('jsonapi');
$request_options = $this->getAuthenticationRequestOptions();
$request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'title']];
$this->request('GET', $url, $request_options);
// Ensure the normalization cache is being incrementally built. After
// requesting the title, only the title is in the cache.
$this->assertNormalizedFieldsAreCached(['title']);
$request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'field_rest_test']];
$this->request('GET', $url, $request_options);
// After requesting an additional field, then that field is in the cache and
// the old one is still there.
$this->assertNormalizedFieldsAreCached(['title', 'field_rest_test']);
}
/**
* Checks that the provided field names are the only fields in the cache.
*
* The normalization cache should only have these fields, which build up
* across responses.
*
* @param string[] $field_names
* The field names.
*/
protected function assertNormalizedFieldsAreCached($field_names) {
$cache = \Drupal::service('render_cache')->get([
'#cache' => [
'keys' => ['node--camelids', $this->entity->uuid()],
'bin' => 'jsonapi_normalizations',
],
]);
$cached_fields = $cache['#data']['fields'];
$this->assertCount(count($field_names), $cached_fields);
array_walk($field_names, function ($field_name) use ($cached_fields) {
$this->assertInstanceOf(
CacheableNormalization::class,
$cached_fields[$field_name]
);
});
}
/**
* {@inheritdoc}
*/
......
......@@ -377,6 +377,9 @@ protected function getData() {
* The JSON:API normalization for the given entity.
*/
protected function normalize(EntityInterface $entity, Url $url) {
// Don't use cached normalizations in tests.
$this->container->get('cache.jsonapi_normalizations')->deleteAll();
$self_link = new Link(new CacheableMetadata(), $url, ['self']);
$resource_type = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName);
$doc = new JsonApiDocumentTopLevel(new ResourceObjectData([ResourceObject::createFromEntity($resource_type, $entity)], 1), new NullIncludedData(), new LinkCollection(['self' => $self_link]));
......@@ -908,7 +911,7 @@ public function testGetIndividual() {
// - to eventually result in a well-formed request that succeeds.
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
......@@ -2110,7 +2113,7 @@ public function testPatchIndividual() {
// - to eventually result in a well-formed request that succeeds.
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
......@@ -2403,7 +2406,7 @@ public function testDeleteIndividual() {
// - to eventually result in a well-formed request that succeeds.
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
......@@ -2723,7 +2726,7 @@ public function testRevisions() {
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute();
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute();
$relationship_url = Url::fromRoute(sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute();
$related_url = Url::fromRoute(sprintf('jsonapi.%s.%s.related', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute();
......
......@@ -369,7 +369,7 @@ public function testPatchPath() {
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
......@@ -435,7 +435,7 @@ public function testGetIndividualTermWithParent(array $parent_term_ids) {
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
$url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
/* $url = $this->entity->toUrl('jsonapi'); */
// $url = $this->entity->toUrl('jsonapi');
$request_options = [];
$request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment