Problem
Every request handled by a JSON:API route results in an instance of JSON:API's ResourceResponse
class. This class implements the CacheableResponseInterface
. This response object is returned for routes and HTTP methods that are uncacheable. When Drupal core encounters a response object that implements the CacheableResponseInterface
it checks to ensure that no "cacheable metadata was leaked" and if it detects this scenario it throws a LogicException
. This can happen when a module does a number of things, but it is most frequently associated with rendering an entity or field in a contrib/custom module and failing to "capture" cache-affecting side-effects of that rendering process. The "proper" way to do this would be to capture that metadata and then associate it with the response object so that Drupal core can eventually cache that response appropriately.
The LogicException
that Drupal core throws is a good thing. Improperly cached response can have nasty consequences (think access bypass vulnerabilities). However, there are many cases in which the HTTP response should be uncacheable. For example, HTTP PATCH
, POST
, and DELETE
methods. Thus, there is an entire class of requests which result in LogicExceptions
entirely unnecessarily.
Steps to Reproduce
- Standard Drupal install
- Enable JSON:API module and configure to accept all methods (GET, POST, PATCH, DELETE)
- Have a module that leaks cacheable metadata. For example, triggering URL rendering in hook_entity_presave().
- Attempt to make a non HEAD/GET request and the LogicException will trigger.
Proposed resolution
If JSON:API were to no longer return a response object that implements CacheableResponseInterface
it would not only be semantically correct, it would immediately prevent a number of erroneous exceptions from being thrown. How can we do that?
- Move the logic in
ResourceResponse
that implementsCacheableResponseInterface
to aCacheableResponseTrait
- Stop implementing
CacheableResponseInterface
on theResourceResponse
class. - Create a new
CacheableResourceResponse
class that extendsResourceResponse
use CacheableResponseTrait
in the newCacheableResourceResponse
- Comb through JSON:API's codebase and convert all response objects to
CacheableResourceResponse
wherever the response is actually cacheable. KeepResourceResponse
in all other cases.
This is identical to how the REST module approached the same situation: https://www.drupal.org/project/drupal/issues/2626298.
A workaround that can be used until this issue lands is https://www.drupal.org/project/jsonapi_earlyrendering_workaround.
Release note
Prior versions of the JSON:API module incorrectly returned ResourceResponse objects that implemented CacheableResponseInterface for all of its responses, even uncacheable ones. This caused avoidable fatal errors under specific conditions.
In order for JSON:API to be more nuanced about cacheable vs. uncacheable responses, a new class has been introduced: CacheableResourceResponse. This class implements CacheableResponseInterface. Accordingly, Drupal\jsonapi\ResourceResponse no longer implements CacheableResponseInterface.
See https://www.drupal.org/node/3163310 for more details
Comment | File | Size | Author |
---|---|---|---|
#87 | interdiff-74-87.txt | 2.6 KB | JordanDukart |
#87 | jsonapi-3072076-87.patch | 24.75 KB | JordanDukart |
#74 | jsonapi-3072076-74.patch | 25.5 KB | JordanDukart |
#60 | jsonapi-3072076-60.patch | 25.48 KB | JordanDukart |
#57 | jsonapi-3072076-57.patch | 22.77 KB | JordanDukart |
Comments
Comment #2
gabesulliceThank you for the excellent bug report @logickal! @logickal++
I agree that this is probably the root cause of the bug.
I completely agree that this is WTF bug. There's no good reason to unnecessarily fail on an uncacheable response. I see two ways to handle it:
Drupa\jsonapi\ResourceResponse
calledCacheableResourceResponse
and only use it for actually cacheable responsesI'll let @Wim Leers weigh in. He might have a third way.
Comment #3
Wim LeersI want to agree this is problematic. But I don't think it is. For
\Drupal\views\Plugin\EntityReferenceSelection\ViewsSelection::validateReferenceableEntities()
to be able to validate, it needs to know which entities are listed by the view, so it needs to run the view.+1 :)
Comment #4
Wim LeersFYI: REST already does what you suggested and what I just +1'd:
\Drupal\rest\ResourceResponse
\Drupal\rest\ModifiedResourceResponse
This was implemented in #2626298: REST module must cache only responses to GET requests. We should essentially do the same for JSON:API.
Comment #5
logickal CreditAttribution: logickal at The Weather Company commentedThanks @gabesullice and @Wim Leers for the feedback and direction on this. Attaching a first pass at a patch - I'm certain I don't have all of the necessary adjustments made to the tests, but the appropriate Kernel tests pass locally.
Comment #6
logickal CreditAttribution: logickal at The Weather Company commentedComment #7
logickal CreditAttribution: logickal at The Weather Company commentedSo, obviously - I specified this issue as 8.7.x but then built the patch on 8.8.x. I could rebase this when I have some more cycles or we can just change the version on the issue. Also as I suspected, there are still issues with the functional tests, I will try to address those next.
Comment #8
logickal CreditAttribution: logickal at The Weather Company commentedComment #9
Wim LeersThanks for working on this, @logickal! 🙏
Comment #10
logickal CreditAttribution: logickal at The Weather Company commentedOk, I got my class inheritance completely wrong in the first patch. Along with fixing that, I've started making adjustments to the functional tests to get them to match reality with this change. Let's see what the testbot thinks....
Comment #11
Wim LeersIt looks like that patch includes lots of unrelated changes 😅
Comment #12
logickal CreditAttribution: logickal at The Weather Company commentedComment #13
logickal CreditAttribution: logickal at The Weather Company commentedOk, THIS patch should have the correct changes.
Comment #16
logickal CreditAttribution: logickal at The Weather Company commentedOk, locally I think I fixed the issue with the failing tests, which was a missed addCacheableDependency() inside EntityResource::createIndividual(). Let's give ol' TestBot a whirl.
Comment #17
gabesulliceThanks for this! This is 90% there. I have a few small nits, a couple remarks and one last medium-sized thing to add.
I think this patch covers a broader scope than the original issue summary and so I think we can clean it up a bit. Also, we should have a regression test to prove the bug we're fixing.
My first thought (IOW, it's off the cuff and if you have a better way, do it!) is to make a test module that implements
hook_entity_presave()
andhook_entity_predelete()
and within those functions, we do something that'll bubble cacheability (like render aDrupal\Core\Url
). Then, the regression test can assert 2xx level responses forHEAD|GET|POST|PATCH|DELETE
. The tests should fail for everything except HEAD/GET before this patch is applied and should pass afterward.🙏 I so appreciate your help @Logickal!
Nit: no need to capitalize anything after
in this sentence.Same. This should be
or without any spaces.These can all remain
ResourceResponse
sinceCacheableResourceResponse
is inherited fromResourceResponse
.Also, many of these renames are on methods that can only ever return instances of
ResourceResponse
. I'm guessing this is just a leftover from some automatic refactoring in your editor :)👌 Good catch, even though these are in "get" methods on the class, they can be called from non-cacheable methods too.
We should use
$request->isMethodCacheable()
here.Nit: there are some extra newlines here.
We can conditionally return a
CacheableResourceResponse
here with:if ($exception->getRequest()->isMethodCacheable()) {...}
It's a little odd, but let's keep the
assert
outside of the conditional block. The assert is here not to validate that the normalization is safe to be passed toaddCacheableDependency
, but rather to enforce the contract that JSON:API serializer is only allowed to returnCacheableNormalization
objects.Nit: let's leave this comment outside of the conditional.
Comment #18
logickal CreditAttribution: logickal at The Weather Company commentedThanks @gabesullice!
1. Fixed.
2. Fixed.
3. Overzealous docblock type renames? Me? :p Fixed.
4. Yep, it’s been a few days, but I’m pretty sure I found this specifically because the methods were called in the context of testPatchIndividual…
5. I swear I had this same thought, must not have circled back on it. Fixed.
6. Ooops. Fixed.
7. It doesn’t look to me like HttpException comes with getRequest(), but maybe I’m missing something? If it’s not possible to determine if the request is actually cacheable, for now I’m turning that back into a ResourceResponse, but I’m honestly not sure if that’s the right course of action.
8. Fixed.
9. Fixed.
I haven’t yet started yet with the regression test, I’ll pick that up the next opportunity I have and start diving in there.
Comment #19
logickal CreditAttribution: logickal at The Weather Company commentedThat solution to item 7 didn't work, looks like it needs to be a CacheableResourceResponse, at least as how the tests are written now. Re-rolled putting that item back the way it was until we can have further discussion.
Comment #20
logickal CreditAttribution: logickal at The Weather Company commentedLast patch came along with some unintended rebase artifacts.
Comment #21
gabesullice#18.7: 🙈 my bad! I pointed you in the wrong direction.
GetResponseForExceptionEvent
has agetRequest
method. My example should have been$event->getRequest()
not$exception->getRequest()
. Fixed.Other than that, I think everything else is good to go once we have a test. Fantastic work @logickal!
Comment #22
Wim LeersThanks for pushing this forward! 👏
\Drupal\jsonapi\ResourceResponse
already doesclass ResourceResponse extends Response implements CacheableResponseInterface
.ResourceResponse
to no longer implement this interface.Comment #23
logickal CreditAttribution: logickal at The Weather Company commented@Wim Leers - We are indeed actually changing ResourceResponse:
Comment #24
Wim LeersD'oh. I'd swear dreditor wasn't showing this?! 😨Can't be. My bad! Please ignore all of #22 😅 I agree the key remaining thing is test coverage then!
Nit: why hardcode this to the concrete class instead of checking the interface?
\Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
also doesn't do this.Comment #25
Wim Leers@gabesullice I think this would also allow you to close #2794385: Node grants make it impossible to return a CacheableResponse from a controller? :)
Comment #26
gabesulliceWHOA, what a blast from the past! That should already have been closed by #2984964: JSON API + hook_node_grants() implementations: accessing /jsonapi/node/article as non-admin user results in a cacheability metadata leak. I went ahead and did that.
Comment #28
bradjones1I just put this on a project and I think it's enough a WTF it would be great to get in sooner rather than later. Tagging for sprints at DrupalCon Amsterdam. Thanks everyone for your work on this! I was relieved to find it in the issue queue rather than scratching my head in bewilderment.
Comment #29
patrickfwestonI modified this for a project running on
8.7.x
and the patch in #21 just needed a slight update to work. I've attached what works for me on 8.7.8.Comment #30
cgoffin CreditAttribution: cgoffin at Sopra Steria commentedOn it during contribution day at DrupalCon Amsterdam 2019. Trying to recreate the patch against the 8.9.x-dev version.
Comment #31
scuba_flyI will be helping with this issue during DrupalCon Amsterdam 2019 as mentor.
Comment #32
cgoffin CreditAttribution: cgoffin at Sopra Steria commentedI refactored the patch against 8.9.x. Here it is.
Comment #33
cgoffin CreditAttribution: cgoffin at Sopra Steria commentedComment #34
nehiryeli CreditAttribution: nehiryeli commentedThe patch on #32 is working on 8.9.x, I've tested it.
Comment #36
rachel_norfolkretagging
Comment #37
nehiryeli CreditAttribution: nehiryeli commentedThe patch on #32 is working on 8.9.0-dev, I've tested it.
Comment #38
scuba_fly@nehiryeli Thank you very much for the work you have done.
I've seen that you did the steps to reproduce the issue, applied the patch and tested that it now works.
@cgoffin Thanks you very much for the rewriting of the patch. I see that it passes now :)
Comment #39
logickal CreditAttribution: logickal commentedThanks to everyone who has jumped in on this during DrupalCon! Unfortunately, I have been a blocker for the AC on this, as I have not been able to refocus on getting a test written. If anyone wants to take a stab at writing a test per Gabe's comment in #17, please do! (see #3072076-17: JSON:API returns a CacheableResponseInterface instance for non-cacheable methods; causes unnecessary exceptions. )
Comment #40
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedRolled tests as per the outline in https://www.drupal.org/comment/13229447#comment-13229447 and noted above.
Comment #41
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedCoding standards in https://www.drupal.org/project/drupal/issues/3072076#comment-13431828, new patch.
Comment #42
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedApologies, first Drupal core issue I've worked on so missed rolling a tests only patch. Included here with the complete to show the fail then succeed.
Comment #43
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #45
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #46
bradjones1@JordanDukart Congrats and THANKS for your first core patch with a test!
Comment #47
borisson_I have a smal remark about the test. I'm not sure about the rest of the code, I don't know the JSON:API subsystem well enough.
I don't think we need to assert that this returns true. We don't need to test the module installer here.
This looks like it could benefit from using a @dataProvider instead of doing it like this.
This adds too much logic to the test class I think.
Comment #48
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedAs I mentioned before this my first core patch, and into testing with 8 at least. The asserting on the module install is all over the existing regression tests which I was cribbing from so that is easily removed.
Haven't explored @dataProvider too much. If I'm understanding correctly you are suggesting providing a separate test function with that annotation to feed in a method and its response code?
Comment #49
borisson_Yeah, but as I think about it more, it probably shouldn't do that, because this is a functional test and that would slow them down a lot, so all you'd need to change is the other remark.
Comment #50
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedUpdated as per @borisson_'s comments in #49.
Comment #51
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #52
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #53
gabesulliceThis looks great! Thank you @JordanDukart! Thanks for helping with the review @borisson_. I agree with your assessment about the data provider. I'm ambivalent about the
$this->assertTrue()
around the module installer. I think it's nice to have that there in case the module ever gets renamed or something. Then the test would fail with a really clear error message whereas it might be a little unclear otherwise.I don't think that difference of opinion is worth holding this patch up though, so RTBC! Great work everyone!
Comment #55
alexpottNot sure why we're pointing to the jsonapi project from core. I guess this is c&p from ResourceResponse - I think there should be a follow-up to address these links - they are all over jsonapi.
So what's interesting here is that ResourceResponse implements CacheableResponseInterface so this is pretty messy and we're not checking on the interface. Did we consider going the other way around? Ie creating an UncacheableResourceResponse? Or removing CacheableResponseInterface and the trait from ResourceResponse?
Ah I see we've done that. Nice. So the point above can be ignored but it'll be great to change conditions like
f ($response instanceof CacheableResourceResponse) {
to test against the interface and not the concrete class.Yeah this route is only for GETs -
$entry_point->setMethods(['GET']);
. Looks good.The comment above the new code doesn't apply to it. Why has it moved position?
Also I think maybe we only need to do the assert about CacheableNormalization if $response is a CacheableResourceResponse.
Comment #56
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedWill roll a patch to address the above.
Comment #57
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedPatch and interdiff attached.
Comment #58
alexpottComment #60
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedPatch in #57 was incomplete. Re-attaching / running tests.
Comment #61
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #62
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #63
gabesullice#55:
1. Good call. Done: #3122113: Convert all PHPDoc links targeting JSON:API contrib issues to target Drupal core issues.
2.3. Good call. It looks like @JordanDukart has already done that too 👌
4. 👍
5. #57 addresses this too. Thanks @JordanDukart!
Comment #64
gabesulliceComment #66
gabesulliceThis was kicked back to needs work for an unrelated test failure. I queued a retest and it's green again. Back to RTBC :)
Comment #67
tolu_ CreditAttribution: tolu_ at IDEAMASN commentedIf you are still pulling your hair out after #60, you might need this https://www.drupal.org/project/jsonapi_earlyrendering_workaround
Comment #69
xjmIt's great to see all the collaboration on this issue and the patience over time.
The latest patch no longer applies to 9.1.x, so it needs a reroll. It also could use that IS update so that reviewers can follow more easily.
Comment #70
xjmSaving issue credits, including for the mentor and participants who worked on the issue during the sprint. Usually we like to see comments that are a bit more detailed about how you review the issue, but since your mentor vouched for your work I'm adding credit. :)
Comment #71
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedWill roll the 9.1 patch, not entirely sure how the IS should be re-written but will bring up in the API-First meeting.
Comment #72
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #73
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedMissed the deprecation in https://www.drupal.org/node/3072702, another patch.
Comment #74
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedGarbled patch in 73, rectifying.
Comment #75
ravi.shankar CreditAttribution: ravi.shankar at OpenSense Labs commentedComment #76
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #77
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #78
gabesulliceThanks for all the effort and shepherding of this issue @JordanDukart! This patch looks perfect to me. I've updated the IS by providing more context and description of these changes.
Comment #79
gabesulliceComment #80
rachel_norfolkHeh - I just used a screenshot of this issue on the contributions page for DrupalCon Global 2020, as an example of a real, live open issue. It looks might it might even be closed before the event - good going everyone!
Comment #81
larowlanI'm not sure referencing what Rest module does is relevant enough to warrant a comment in the code that may need to be removed in the future, fair enough here on the issue, but in the code?
alexpott pointed this out earlier too - why are we linking to an issue in json api module?
nit: we could return early here and avoid an else
this is a BC break - there is possibly downstream code that assumes the previous interface was implemented I think we'd need to do the opposite, i.e. keep the existing behaviour but add a uncacheable flavour
I realise alex also flagged this above and was OK with it, but I'm not sure its the right approach for BC, will ping him to discuss
None of the other feedback warrants a new patch, so just putting to needs review till I can discuss with him
Comment #82
alexpottRe #81.4 The class has the following documentation - (dreditor-- for lacking context).
It was added to core with this documentation
I think we should only make this change in minor but I think we can make the change this way otherwise our documentation is not true.
Comment #83
larowlanThanks Alex, I'd missed that.
This is 9.1 only then
Can we get a release notes snippet and change notice here
Comment #84
gabesulliceCR created: https://www.drupal.org/node/3163310
Comment #85
mglaman+1 to the CR
Comment #86
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedWill handle #81.1-3.
Comment #87
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedRE: #81.1
Will remove this reference.
#81.2
@alexpott pointed this out above and it spawned https://www.drupal.org/project/drupal/issues/3122113. Will update the referenced links in this patch to point to Drupal.
#81.3
Will return early.
Interdiff + patch attached. Re-rolled some things as my previous patch in #74 no longer applied cleanly in 9.1.x see: https://www.drupal.org/project/drupal/issues/3133033 and https://www.drupal.org/project/drupal/issues/3138766.
Comment #88
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedComment #89
gabesulliceThanks for addressing #81 @JordanDukart! This looks good to me.
Comment #91
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedUnrelated test fail.
Comment #93
JordanDukart CreditAttribution: JordanDukart as a volunteer commentedAnother unrelated test fail.
Comment #94
catchCommitted 83c181f and pushed to 9.1.x. Thanks!
Added the release note.