At the moment JSON:API is not able to normalize a list of revisions of the same entity because of normalizations' cache. If you pass 10 revisions the normalized structure for all of them will be the same since the first result is cached and used because of matching cache tags, which are resource name and entity ID.

I understand that this functionality is not offered by JSON:API out of the box and to achieve this I wrote a custom code that relies on PHP API (the jsonapi.entity_resource service in particular) even despite a warning that developers shouldn't do that.

Nevertheless, the solution to overcome this problem is simple and seems so logical so I don't think it should stay overboard.

Proposed resolution

Include a revision ID to the cache tags.

API changes

Every 403 error object (omission) receives two new members in the links.via.meta node: resourceId and resourceVersion. The existing value at links.via.href may include the resourceVersion GET query parameter if a revision ID is known (entity is revisionable and has a revision).

Example:

{
  "links": {
    "via": {
      "href": "https://example.com/node/12?resourceVersion=id%3A12",
      "meta": {
        "resourceId": "entity_uuid",
        "resourceVersion": "12"
      }
    },
  }
}

The resourceId is always present and is a UUID of omitted entity. The resourceVersion is always present and can be null in the case when entity has no revision (Drupal\Core\Entity\RevisionableInterface::getRevisionId() returns null) or non-revisilable (the entity class does not implement the Drupal\Core\Entity\RevisionableInterface). A result of getRevisionId() otherwise.

Issue fork drupal-3149854

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

BR0kEN created an issue. See original summary.

br0ken’s picture

Status: Active » Needs review
Issue tags: +Needs tests
StatusFileSize
new1.08 KB
br0ken’s picture

Status: Needs review » Needs work

The last submitted patch, 2: 3149854-2.patch, failed testing. View results

br0ken’s picture

Status: Needs work » Needs review
StatusFileSize
new2.16 KB
br0ken’s picture

Issue tags: -Needs tests
StatusFileSize
new11.6 KB

The test to show the problem.

br0ken’s picture

StatusFileSize
new16.9 KB
new10.25 KB

The fix.

br0ken’s picture

StatusFileSize
new17.26 KB
new1.03 KB

Probably we don't need to have the `toCache` array associative.

The last submitted patch, 6: 3149854-6-do-not-commit.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 8: 3149854-8.patch, failed testing. View results

br0ken’s picture

Assigned: br0ken » Unassigned
Status: Needs work » Needs review

Setting back to NR.

wim leers’s picture

Issue tags: +D8 cacheability

Woah, good catch!

  1. +++ b/core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php
    @@ -97,9 +97,7 @@ public function saveOnTerminate(ResourceObject $object, array $normalization_par
    -    $this->toCache[$key] = [$object, $normalization_parts];
    +    $this->toCache[] = [$object, $normalization_parts];
    

    Why is removing the key okay?

    Because the key was not used anyway!

  2. +++ b/core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php
    @@ -110,8 +108,7 @@ public function saveOnTerminate(ResourceObject $object, array $normalization_par
    -      list($object, $normalization_parts) = $value;
    -      $this->set($object, $normalization_parts);
    +      $this->set(...$value);
    

    Woah! First time I'm seeing this 🤓

  3. +++ b/core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php
    @@ -162,9 +159,16 @@ protected function set(ResourceObject $object, array $normalization_parts) {
    +    if ($resource_type->isVersionable()) {
    +      $keys[] = $object->getVersionIdentifier();
    +    }
    +
         return [
           '#cache' => [
    -        'keys' => [$object->getResourceType()->getTypeName(), $object->getId()],
    +        'keys' => $keys,
    

    👍 This makes total sense! This is the root of the bug described by the issue title.

  4. +++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
    @@ -268,6 +269,13 @@ protected function normalizeOmissionsLinks(OmittedData $omissions, $format, arra
    +        // The collection of revisions of a single entity will have the same
    +        // link so we need to make it unique.
    +        $href = $error['links']['via']['href'];
    +        if (!isset($hrefs_counts[$href])) {
    +          $hrefs_counts[$href] = 0;
    +        }
    +        $hrefs_counts[$href]++;
             // JSON:API links cannot be arrays and the spec generally favors link
             // relation types as keys. 'item' is the right link relation type, but
             // we need multiple values. To do that, we generate a meaningless,
    @@ -275,9 +283,9 @@ protected function normalizeOmissionsLinks(OmittedData $omissions, $format, arra
    
    @@ -275,9 +283,9 @@ protected function normalizeOmissionsLinks(OmittedData $omissions, $format, arra
             // random salt and the link href. This ensures that the key is non-
             // deterministic while letting use deduplicate the links by their
             // href. The salt is *not* used for any cryptographic reason.
    -        $link_key = 'item--' . static::getLinkHash($link_hash_salt, $error['links']['via']['href']);
    +        $link_key = 'item--' . static::getLinkHash($link_hash_salt . $hrefs_counts[$href], $href);
    

    🤔 This is over my head right now 😅 Why are we appending a count to a hash salt?

br0ken’s picture

Assigned: Unassigned » br0ken
Status: Needs review » Needs work

#12.4 Imagine a user that doesn't have access to the entity's revisions and opens a page with 10 of them. The `omitted` will contain only one (latest) section instead of all 10. This happens because all `href` for all revisions of a single entity are the same which leads to producing the same hash hence the same array key the value for which will always be overwritten. Actually I feel like this needs a test too.

br0ken’s picture

Assigned: br0ken » Unassigned
Status: Needs work » Needs review
StatusFileSize
new3.62 KB
new18.69 KB
br0ken’s picture

StatusFileSize
new18.69 KB
new1.11 KB

Remove global NS for SPL functions as Drupal core doesn't use it.

br0ken’s picture

StatusFileSize
new8.45 KB
new22.06 KB

Here is a more elegant solution for storing all omissions which also provides a version ID in meta/version.

Status: Needs review » Needs work

The last submitted patch, 16: 3149854-16.patch, failed testing. View results

br0ken’s picture

And of course the more elegant the solution is the more work it requires.

Version: 9.0.x-dev » 9.1.x-dev

Drupal 9.0.10 was released on December 3, 2020 and is the final full bugfix release for the Drupal 9.0.x series. Drupal 9.0.x will not receive any further development aside from security fixes. Sites should update to Drupal 9.1.0 to continue receiving regular bugfixes.

Drupal-9-only bug reports should be targeted for the 9.1.x-dev branch from now on, and new development or disruptive changes should be targeted for the 9.2.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.1.x-dev » 9.3.x-dev

Drupal 9.1.10 (June 4, 2021) and Drupal 9.2.10 (November 24, 2021) were the last bugfix releases of those minor version series. Drupal 9 bug reports should be targeted for the 9.3.x-dev branch from now on, and new development or disruptive changes should be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

br0ken’s picture

Status: Needs work » Needs review
StatusFileSize
new22.56 KB

Re-roll for 9.3.x. Tests won't pass as I don't know an easy way to overcome the generalized assertResourceErrorResponse method and how to pass revision IDs there from so many places. The tests are too cumbersome to work with. From the operability perspective, the patch fixes the issue. Hopefully, someone will have advice or idea about the tests.

br0ken’s picture

StatusFileSize
new22.32 KB
new601 bytes

I accidentally named the patch incorrectly plus left there an unused import.

br0ken’s picture

StatusFileSize
new661 bytes
new21.98 KB

I should have been more attentive.

Status: Needs review » Needs work

The last submitted patch, 23: 3149854-23.patch, failed testing. View results

br0ken’s picture

Status: Needs work » Needs review

Setting back to NR to attract attention.

bbrala’s picture

Thanks @BR0keN, i think this is a smart addition and will make life easier later on.

I've gone through the code and there is a few small things that i think would need work. More importantly, there is quite a few test failures that seem to point at some empty array values. We do need to get to the bottom of that.

  1. +++ b/core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php
    @@ -97,9 +97,7 @@ public function saveOnTerminate(ResourceObject $object, array $normalization_par
    +    $this->toCache[] = [$object, $normalization_parts];
    

    Removing the key here feels a bit scary, but i can't seem to find a way this would create issues.

  2. +++ b/core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php
    @@ -110,8 +108,7 @@ public function saveOnTerminate(ResourceObject $object, array $normalization_par
    +      $this->set(...$value);
    

    I understand this seems nicer, but i think it is wise to move this change to a folow up issue to minimize the change surface of this patch

  3. +++ b/core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php
    @@ -162,9 +159,16 @@ protected function set(ResourceObject $object, array $normalization_parts) {
    +        'keys' => $keys,
    

    Another reason for #3252424: Follow-up: ResourceObjectNormalizationCacher should get tags from ResourceObject instead of building them i think.

  4. +++ b/core/modules/jsonapi/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php
    @@ -52,8 +53,10 @@ protected function buildErrorObjects(HttpException $exception) {
    +        $errors[0]['links']['via']['meta']['resourceId'] = $entity_uuid;
    
    +++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
    @@ -265,24 +265,32 @@ protected function normalizeOmissionsLinks(OmittedData $omissions, $format, arra
    +        $link_key = 'item--' . static::getLinkHash($via['meta']['resourceVersion'] ?? random_bytes(8), $via['href']);
    

    Shouldn't we keep this outside the loop? Don't think there is a real reason to move it inline and calculate it in the loop instead of outside.

br0ken’s picture

Hey Björn, thanks for your attention to this.

  1. The key has no sense since unused. Moreover, if we keep it, the revision ID must be appended to it (and god knows what else later).
  2. IMO, this is a one-liner and doesn't produce additional mental load to read the patch. If you insist to revert it though, I'll do.
  3. Related, agree, but out of scope of this issue.
  4. Nope, we definitely must do this on each iteration. Otherwise, only omissions for the latest revision will be in the response instead of having them for each revision of the same entity.

The problem with tests is due to the generalized method used across the entire test suite, which is not flexible enough to accommodate it for the new changes. I'm really disappointed that I need to rewrite lots of tests code to make them pass with the changes this patch introduces.

If you wish, we could chat more about this in Slack.

bbrala’s picture

  1. Yeah, no concrete worries, just a feeling :)
  2. This might come back to bite us, but keep it for now.
  3. Yes.
  4. Ah yeah, good one sorry.

Wegarding the tests, we are quite explicit in what we expect as output, so if we change the output, the tests will fail. Not sure how we can go around that. I remember #3036593: Add 'drupal_internal__target_id' to JSON:API representation of entity reference fields, because that can't be retrieved from the target resource for target entity types without corresponding resources, was a little bit of effort as seen in the commit.

I've also pinged you onm slack

bbrala’s picture

We had some discussion on slack regarding this issue:

br0ken 1 hour ago
Yes, you’re right. The main problem is with the assertResourceErrorResponse which is called from lots of palces

br0ken 1 hour ago
null because the entity is not revisionable or has no revision

br0ken 1 hour ago
To incorporate the changes to the assertResourceErrorResponse we have to somehow pass revision info to it. It’s relatively possible for individual requests, but I can’t see a way of doing that for collections. (edited)

Björn Brala (bbrala) 1 hour ago
Hmm, that is a good point.

Björn Brala (bbrala) 1 hour ago
I'm trying to think, but need to see the full error for a collection. Another day where those generic test methods are making it harder to solve :x

br0ken 1 hour ago
Would it make sense to not precisely check the output but, let’s say, the count of omissions only?

Björn Brala (bbrala) 1 hour ago
Hmm

Björn Brala (bbrala) 1 hour ago
I might be on to something.

Björn Brala (bbrala) 1 hour ago
When getting resource links it uses ResourceResponseTestTrait::getResourceLink

Björn Brala (bbrala) 1 hour ago
Should't that be aware if versioning was turned on?

Björn Brala (bbrala) 1 hour ago
But that doesnt solve the meta info..

Björn Brala (bbrala) 35 minutes ago
"Would it make sense to not precisely check the output but, let’s say, the count of omissions only?"
That could work, but i have a feeling we can actually build the correct expected response in getExpectedCollectionResponse, we should really have the information needed...

Björn Brala (bbrala) 34 minutes ago
And no wonder these tests are slow when getExpectedCollectionResponse does singe requests for the collection and then merges them xD

Björn Brala (bbrala) 26 minutes ago
I think i found it.

Björn Brala (bbrala) 24 minutes ago
toCollectionResourceResponse is building the response. It recieves the correct reponses with the proper version information. Then i comes to if (!empty($response_document['errors'])) { which adds the error information, calling static::errorsToOmittedObject. This function should be extended with the meta we expect. The current loop is:

Björn Brala (bbrala) 24 minutes ago

foreach ($errors as $error) {
      $omitted['links']['item--' . substr(Crypt::hashBase64($error['links']['via']['href']), 0, 7)] = [
        'href' => $error['links']['via']['href'],
        'meta' => [
          'detail' => $error['detail'],
          'rel' => 'item',
        ],
      ];
    }

Björn Brala (bbrala) 23 minutes ago
Which means missing version info

br0ken 22 minutes ago
It’s never executed for individual requests though

Björn Brala (bbrala) 20 minutes ago
Nope, that is mostly getExpectedDocument, which is manual right? So for example NodeTest::getExpectedDocument. And exactly what i mentioned in the comment on the issue?

Björn Brala (bbrala) 20 minutes ago
Dont think we can really get around defining what we expect as a returning document.

Björn Brala (bbrala) 19 minutes ago
For single's

br0ken 17 minutes ago
I’m lost, to be frank. Will try to re-read the thread

Björn Brala (bbrala) 15 minutes ago
No worries, gonna try and suggest a change for the collections

br0ken 14 minutes ago
Still not getting it, sorry.
I think that the way to make it work is to somehow pass entities during the call assertResourceErrorResponse. That way the expected document can be built. (edited)

Björn Brala (bbrala) 14 minutes ago
For the failing tests that are sourced from the getExpectedDocument in the test classes themselves the array needs to be changed

Björn Brala (bbrala) 12 minutes ago
I'm smoking right now, I'll try and finish a fix for the error information missing the meta information of the version and show the code
:+1:
1

Björn Brala (bbrala) 6 minutes ago
Yay, the collection test is now green locally.

br0ken 4 minutes ago
Hmm, that’s great! Was I over-dramatic estimating the complexity?

Björn Brala (bbrala) 3 minutes ago
git diff modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php

diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
index 993ff5b57b..11c4080564 100644
--- a/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
@@ -594,13 +594,17 @@ protected static function errorsToOmittedObject(array $errors) {
       ],
     ];
     foreach ($errors as $error) {
-      $omitted['links']['item--' . substr(Crypt::hashBase64($error['links']['via']['href']), 0, 7)] = [
+      $errorInfo = [
         'href' => $error['links']['via']['href'],
         'meta' => [
           'detail' => $error['detail'],
           'rel' => 'item',
         ],
       ];
+      if(isset($error['links']['via']['meta'])) {
+        $errorInfo['meta'] += $error['links']['via']['meta'];
+      }
+      $omitted['links']['item--' . substr(Crypt::hashBase64($error['links']['via']['href']), 0, 7)] = $errorInfo;
     }
     return $omitted;
   }

Björn Brala (bbrala) 3 minutes ago
I had to use the debugger in phpunit to find out how the hell this was done. So ehh, its just overly complex sometimes how tests are done.

Björn Brala (bbrala) 2 minutes ago
The isset part might be better of being explicit in the keys it copies perhaps. But im not sure.

Björn Brala (bbrala) 1 minute ago
This change should at least reduce the amount of errors. But as you can see in this commit you will still need to go through the tests and adjust the getExpectedDocument functions to include the revision info where it is relevant.

br0ken’s picture

Status: Needs work » Needs review
StatusFileSize
new23.11 KB
new982 bytes

Added proposed changes. This should help testCollection tests however testGetIndividual and testPatchIndividual will still fail.

bbrala’s picture

Lets have a look after this run. I think this will probably only be updating getExpectedDocument for a few classes.

Status: Needs review » Needs work

The last submitted patch, 30: 3149854-30.patch, failed testing. View results

br0ken’s picture

Summary of 65 fails:

  1. testGetIndividual - 45
  2. testPatchIndividual - 14
  3. testRevisions = 2
  4. testPatchPath - 1
  5. testCollection - 1
  6. testPatchDxForSecuritySensitiveBaseFields - 1
  7. testPatchSecurityOtherUser - 1

It's really hard to edit these tests. Offtopic: lines longer than 140 chars are hard to read. I also often stumble upon 200+ chars per line.

br0ken’s picture

A 5-minutes fix and already more than 10 hours to adjust messy tests.

bbrala’s picture

I'm going to try some things in a seperate branch to see if i can clean up how tests is configured.

bbrala’s picture

Opening merge request of my changes to have tests run without changing the branch by @BR0ken

bbrala’s picture

Issue tags: +Needs change record

Ok, the changes I made to how the tests are run are a lot cleaner I think. The following is needed still:

  1. The IS needs updating. There is meta information added, this needs to be added to the issue summary.
  2. We also need to make sure the meta tag is added to the right endpoints. Is is added to the correct endpoints now? It seems for example that /jsonapi/user--user/relations/revision_id doesn't provide that information. Is that correct? It really does need to be clear when it is added.

After that i'll have a look if the tests are correct in respect to the proposed change, but for that I really want to know explicitly what is added.

Also; added 'Needs change record' since this change will need a change record.

bbrala’s picture

Another thing i thought if, why should the revisionId be included in the meta if it is NULL, wouldn't this mean, there is no revision and shouldn't it then just be omitted?

br0ken’s picture

Issue summary: View changes

Two new members are added to the omission error object only (see updated IS for details).

Shall we now continue in the MR you've opened?

br0ken’s picture

bbrala’s picture

Personally I find mr's easier to work with :)

br0ken’s picture

Hmm...

I found two occurrences of the The current user is not allowed to view this relationship.. In both cases, the EntityAccessDeniedHttpException is constructed and I suspect it's not getting normalized and that's why necessary meta members are missing.

Edit: ^ when it passes through ResourceTestBase::getExpectedCollectionResponse.

br0ken’s picture

🤞

br0ken’s picture

Having #3199696: Add language support to ResourceObject merged to 9.4.x and not backported to 9.3.x will cause a conflict if this gets committed to 9.3.x. Two options:

  1. Backport #3199696: Add language support to ResourceObject to 9.3.x.
  2. Target this issue to 9.4.x.
br0ken’s picture

Issue summary: View changes

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.15 was released on June 1st, 2022 and is the final full bugfix release for the Drupal 9.3.x series. Drupal 9.3.x will not receive any further development aside from security fixes. Drupal 9 bug reports should be targeted for the 9.4.x-dev branch from now on, and new development or disruptive changes should be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.9 was released on December 7, 2022 and is the final full bugfix release for the Drupal 9.4.x series. Drupal 9.4.x will not receive any further development aside from security fixes. Drupal 9 bug reports should be targeted for the 9.5.x-dev branch from now on, and new development or disruptive changes should be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.5.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

stefan.butura’s picture

StatusFileSize
new46.54 KB

See next comment

stefan.butura’s picture

StatusFileSize
new45.25 KB

Attached an attempt at creating a patch from MR 2417 that is compatible with Drupal 10.3.x

zarpele’s picture

StatusFileSize
new42.05 KB

Attached a patch from MR 2417 that is compatible with Drupal 10.4.x

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.