Problem/Motivation
The issue is about adding a Relationship value object to wrap/replace entity reference fields within JSON:API. Having this abstraction is valuable because, unlike an entity reference field, a JSON:API relationship can have unique links/URLs of its own and is able to reference any resource regardless of its type (an entity reference field is limited to just one entity type). The former abstraction is useful for the JSON:API Hypermedia module and the latter will lay useful groundwork for supporting dynamic entity reference fields, reverse relationships, or resource types that aren't tied 1:1 with the entity system (useful for component-based decoupled designs).
Proposed resolution
Add a Relationship object that can act as a container for the resource identifiers that represent a relationship between a resource and other resource(s). This container can be used within a resource object or within a top-level object, depending on the context of the request (is it a request for a resource object or is it a direct relationship request).
Remaining tasks
Reviews, commit
User interface changes
None.
API changes
"It's complicated". See #32.1, #36.7, & #38.7
Data model changes
None.
Release notes snippet
None.
| Comment | File | Size | Author |
|---|---|---|---|
| #57 | 3036285-57.patch | 41.53 KB | gabesullice |
| #57 | interdiff.txt | 1.23 KB | gabesullice |
| #51 | 3036285-51.patch | 41.85 KB | spokje |
| #40 | 3036285-40.patch | 41.3 KB | gabesullice |
| #40 | interdiff.txt | 718 bytes | gabesullice |
Comments
Comment #2
gabesulliceComment #3
gabesulliceHere is what I have so far, it depends on #3036286: Clean-up: JsonApiDocumentTopLevelNormalizer and children to prep for further clean-ups.
I know there are kernel test failures (probably some functional ones too, I haven't tested them all). I haven't finished fixing those, but I've attached an interdiff of my progress in case someone else wants to pick up where I left off.
Comment #4
gabesulliceReroll onto #3036286-14: Clean-up: JsonApiDocumentTopLevelNormalizer and children to prep for further clean-ups.
Comment #5
gabesullice#3036286: Clean-up: JsonApiDocumentTopLevelNormalizer and children to prep for further clean-ups landed!
Comment #6
gabesulliceHere's a reroll of #4 for #3036286: Clean-up: JsonApiDocumentTopLevelNormalizer and children to prep for further clean-ups. However, this will fail pretty hard because it still needs to be rerolled to account for #3037452: Clean-up: ResourceObject should not be coupled to entities
Comment #7
gabesulliceRerolled per #6.
Comment #8
gabesulliceWhoops, looks like I lost the relationship meta meta during the reroll.
Comment #9
gabesulliceRerolled.
Comment #10
wim leers"Waiting for branch to pass"
/me looks
What now? I just checked, and it's definitely still in D8 HEAD, in the
8.8.xbranch. What's going on here?Comment #11
gabesulliceMoving to the core queue and marking as a blocker to JSON:API Hypermedia module, which would like to hyperlink relationship objects in addition to top-level and resource objects.
Comment #12
gabesulliceRerolled #9 for 8.8.x
Comment #13
gabesulliceComment #14
gabesulliceComment #15
wim leers(Want you to know I'm keeping an eye on this, I'll do a review once this is green or when you ask me to review it. Its status is after all.)
Comment #16
gabesulliceThanks! I'll definitely update the metadata once this needs review and I'll reach out to you too.
Comment #17
gabesulliceComment #18
gabesulliceShould be green 🙏
Comment #20
gabesullicehahaha, I shouldn't have said that! Looks like a testbot problem. Re-queuing.
Comment #22
gabesulliceGah, the failure in #17 and #18 seems to be related to the order of includes, which is not deterministic.
Comment #24
wim leersThe issue summary doesn't explain what problem this solves. #11 says this is a contributed project blocker. Ideally the issue summary would lay it out very concretely how this issue unblocks that.
Here is a first thorough pass. I covered at least 90%, but I think covering (and grokking) truly 100% will be a lot simpler once you address some of the feedback and answer some questions :)
So this hardcoded way of how links on relationships are generated disappears thanks to this new abstraction.
Simultaneously this reduces the coupling of the JSON:API module to entities/fields (thanks to the removed
$entity->get(…)). That brings us one step closer to being able to expose non-entity data, which is important for #3032787: [META] Start creating the public PHP API of the JSON:API module and friends.👍
.
🤔 This seems like an optional optimization? If not, why is this necessary? If it is not essential, let's move that into a separate issue, I'll instantly RTBC it :)
🤔 Why is
NullIncludedDatano longer possible?This is a tightening. 👍
🤔🤔🤔👍 We detect a 403 exception and package it up in an include. Because the include is not accessible. I had to think this one through to get it, I'd have sworn initially that this was wrong!This is a consequence of the changes in
\Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields().🤔👍 This is now doing field access checking at an earlier time because we want to return aRelationshipobject for entity reference fields instead of just aFieldItemListInterfaceinstance. If it's accessible, we return aRelationShipobject if it is an entity reference field, otherwise we return exactly the same as before.If it's not accessible, we return a 403 exception, both for entity reference fields and all other fields. This is why
\Drupal\jsonapi\IncludeResolver::resolveIncludeTree()was able to remove field access checking.That is itself a big win, because now the include resolver does not need to bother with field-specific things anymore. 🥳
🤔 Why do we need to treat
Relationshipobjects different than others? What is special about them? Linking to relevant information in the JSON:API spec would make this crystal clear.🤔 "context" sounds very abstract, but this is always returning a
ResourceObject. Can't we make the name more concrete too then?🤔 These look like bugfixes that could land separately?
😍😍😍
😍😍😍
🥳 Hah! This just confirms what I wrote earlier about this reducing the coupling of JSON:API to entities/fields! 🤓
🥳🆚🤔
I'm not sure what to think of this one. On the one hand, it's great to see the
$context['account']thing disappear. On the other hand, this seems unrelated to this issue?🤔 I understand the first one:
ResourceObject::getField()now returns aRelationshipobject for relationship fields.But the second one I do not understand. What triggered that change?
🤔 This one could use a comment. This seems to restrict the filtering further. Which is not what you wrote in #22. I'm probably missing something :)
A LOT of changes here. This saddened me. You should not have to deal with that.
So this reminded me to address the feedback at #3060836 — see #3060836-9: Remove 99% of \Drupal\Tests\jsonapi\Kernel\Controller\EntityResourceTest since it is a subset of the functional tests. Hopefully that lands soon, then this patch will become a fair bit smaller :)
Comment #25
wim leersRegarding #24.17: YAY, #3060836: Remove 99% of \Drupal\Tests\jsonapi\Kernel\Controller\EntityResourceTest since it is a subset of the functional tests landed, which means all of that can be omitted from this patch 🥳 I am so curious to see the reduction in patch size that I did that for you 🤓
From
19 files changed, 479 insertions, 288 deletions.in 66.67 KB to18 files changed, 369 insertions, 251 deletions.in 57.36 KB. 😀Wrong patch attached, see next comment for the right patch.
Comment #26
wim leers#25 contained the wrong patch 🤦♂️
Comment #27
gabesullice#24:
1. 👍
2. 👍
3. It's not an optimization, it's cleaning up what will become a dead code path. The removed code is there because
EntityResource::getRelationshipused to pass a self link of its own which we did not want to be overridden. We don't have that exception anymore.4. It's still possible.
NullIncludedDatais a descendant ofIncludedData.5. 👍
6. 👍
7. 😄
8. ✅ I updated this comment. The gist is that when you fetch a relationship directly, you want the
relatedlink to appear in the top-level links, but you don't want theselflink on the relationship object to override (and lose) any query parameters on the top-levelselflink (like an include).9. Hmm, I kinda like the symmetry it creates with the
LinkCollectionobject though. I suppose it could begetContextResourceObject, that feels a little clunky though. WDYT?10. Nope, that used to happen elsewhere. See the removed
EntityReferenceFieldNormalizer, lines 43-45.11. 😄
12. 😄
13. 😄
14. 🤔 I think you're right. It's not related... any longer. In an earlier iteration, I had completely removed access checking in the normalizer and thus
$context['account']along with it, since in theoryResourceObject::extractContentEntityFieldsis already performing the access check (see #24.7). Unfortunately, I ran into cacheability issues with sparse fieldsets and had to undo it.In answering that, I think I found a subtle cacheability bug in the current patch. I'll address this in a subsequent interdiff.
15. Per #24.7, if a relationship or attribute is forbidden, it becomes an
EntityAccessDeniedException. Don't worry about this for now, I think once I address 14 above, both of these will become clearer.16. ✅ Added a comment.
17. 🙏 THANK YOU SO MUCH FOR THAT!
Comment #28
wim leersgetHost()orgetSource()orgetOrigin()or something else like that. But that's kinda odd too. I guessgetContext()still allows for the freedom for other kinds of contexts in the future? But if that's the intent, then we shouldn't typehint toResourceObject?Comment #29
gabesullice@Wim Leers and I got on a phone call and discussed the current patch (esp. #27.14-15). I explained to Wim how I had realized that I almost introduced a subtle cache bug that would have been a security issue (whew!) and how I thought the "right" way to fix that cacheability issue would create a much bigger patch than we already have.
Wim then asked to take a step back and ask ourselves if there might be smaller scoped patch hidden in this big one. At first, I thought that that would be difficult because this patch was 99% all about the concept of creating a relationship object inside of
ResourceObject::createFromEntity()and using that to represent relationships between entities.As I thought more about it, I realized we could independently add the
Relationshipobject and not create it within theResourceObjectconstructor, but in two places elsewhere. By doing that, we'd get almost all of the benefits (like code clarity and less coupling to the entity system) with a lot less turbulence.SO... @Wim Leers++. Your insightful question really helped out!
The attached patch still adds a
Relationshipobject that can be created from an entity reference. Having that object is valuable because, unlike an entity reference field, a JSON:API relationship can have unique links/URLs of its own and is able to reference any resource regardless of its type (an entity reference field is limited to just one entity type). The former abstraction is useful for the JSON:API Hypermedia module and the latter will lay useful groundwork for supporting dynamic entity reference fields, reverse relationships, or resource types that aren't tied 1:1 with the entity system (useful for component-based decoupled designs).However, the attached patch no longer creates the Relationship inside a ResourceObject, which lets us change less code/tests related to access and cacheability. It think it's a lot easier to grok. That simple difference reduced the patch by 20k!
(I added the above to the issue summary, though I still think the issue summary needs more work).
Comment #30
gabesulliceBack at you @Wim Leers.
Comment #31
gabesullice#28.9:
If the alternative is
getHost()orgetSource()orgetOrigin(), then I'll keep arguing for "getContext()". Here's why:I'm using "context" because a JSON:API relationship object is just a special kind of link object (so much so that @dgeb and I have discussed that we might one day add an
hrefto resource identifiers to unite the concepts). Links have a context and a target. It's typehinted to a resource object because a relationship always exists in the context of a resource object.In JSON:API Hypermedia, the code one would write might look like this:
Given that , I'd bite my tongue and accept
getResourceObjectif you insisted. It might be that all that reasoning above only makes sense in my pedantic mind :PComment #32
wim leers#29: Yay! 🥳 The paragraph is super helpful by the way.
I think it'd be a valuable addition to the issue summary. It will make it easier for a non-JSON:API expert (including core committers) to understand the value and justification for this patch.Hah, you already did that! 👏From 57.78 KB to 36.64 KB 😲👍
(For when this is RTBC.)
ℹ️ Dear core committer: see
\Drupal\jsonapi\Serializer\Serializer::__construct()— we already very explicitly prevent modules adding or overriding normalizers! That's why this is not a BC break.We have #3032787: [META] Start creating the public PHP API of the JSON:API module to start exposing a public PHP API.
So … it looks like these occurrences of
instanceof Datashould already have beeninstanceof ResourceObjectData?👍 This is just an addition, to indicate that a
LinkCollection's context can now also be aRelationobject. That's exactly what the goal of this issue is.🤓 #31: that RFC link would be valuable to add to the interface, because it helps clarify the intended semantics of the term "context" in this particular case.
EDIT: While I initially was completely convinced by #31, I realized that #31's argument and reference to that RFC was specifically about links, but this value object is not for a link. It's for a relationship. So perhaps link context is more appropriate? Because this reminded me again of what I wrote in #28.9: the "context" could arguably be either the source or the destination of the relationship!
So I think this still needs a little more refinement. Perhaps we keep the name the same, but then the docs need to address all potential misinterpretations.
(It doesn't help that
\Symfony\Component\Serializer\Normalizer\NormalizerInterface::normalize()also has a$contextparameter…)🤔 Should we add an
assert(…)to verify this is indeed a public field name? We've had bugs in the past where we forgot the translation from public to internal or vice versa.That'd be a cost that is only incurred when assertions are enabled.
🔎🐛 s/entity/resource type/
🔎🐛 s/resource/relationship/
🤔 Do we need that second operand here?
$context_is_versionableshould be sufficient?If it's for prudence, then I think this is more appropriate:
🙏 This section could use a comment.
🤔 This looks like a BC break?
🤔 Apparently links are now being sorted?
🤔 Why can this one still be removed?
Comment #33
wim leersComment #34
gabesullice#32:
1. thanks for that! Exactly right.
2. Yep.
3. Mhmm, mhmm :)
4.
This is why I opened that argument by saying that
🤔 I don't see how a context could be either the source or the destination of a link. What part of your argument am I missing?
Perhaps the disconnect is that you're thinking of a relationship as a representation of a bidirectional reference:
A<->B. The spec doesn't see a relationship like that though. A relationship object represents a unidirectional reference:A->B. We can deduce that by that fact that a relationship object can only carry the resource identifier for B under itsdatamember. To represent a bidirectional reference, a relationship object would have to contain resource identifiers for both the left- and right-hand side of the relationship and a completely different kind ofselflink:We know that's not how a relationship is represented though. It only has a data member for the right-hand side of the relationship and its
selflink (its identity) comes from its location. Put another way, the relationship must be "contextualized" to be understood. That context either comes from its location within a resource object or its location under a URL that is derived from a resource object URL.To keep beating this dead horse... in #28, you said:
We're doing REST with representations of our data, not creating a database protocol. We can have multiple "resources" to represent the same data from different perspectives. If the connection were stored separately, we would just create two distinct relationship URLs to represent that information from either the left-hand view of the connection or the right-handed view of the connection.
I definitely agree that more docs will help here... This conversation is proof positive! (I think it's actually a fun conversation though :) ofc, maybe I'm a masochist)
5. Unfortunately, there's just not a reliable way to do that until #3014277: ResourceTypes should know about their fields lands.
6. Good catch!
7. Done.
8. I think it was only there to ensure that
getRevisionIdwas available. Moved that to an assert.9. Done!
10. I don't think so. It's actually fixing a small/subtle bug: a top-level
selflink is supposed to return "the link that generated the current response document." In this circumstance, the request URL does not have a resource version and we were erroneously returning it. If a client is following aselflink on a relationship from a resource object, it'll still have the resource version query parameter since it'll be in the URL that generated the response. This is related to #27.8.11. No, I think it's just a consequence of how
JsonApiDocumentTopLevelmerges relationship links. They're not in the same order that they were before, but not because they were sorted.12. That context value is never used. I did some digging and found that it hasn't been used since #3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization. That issue should have cleaned this up.
Comment #35
wim leersDrupal entities happen to store references (relationships) on the source. The source stores a reference to the destination. It could've been implemented differently. And non-entity data sources (
ResourceTypes) could do it differently.That's why I say that a
Relationship'scontextneeding to be aResourceObjectis ambiguous.Aha! ☝ 😀
You're right. I wish the spec explicitly used the word "unidirectional".
Improving the docs then is sufficient, and you did exactly that in #34 👍
Comment #36
larowlanNice work, I love value objects :)
do we need to profile this change, there's a lot going on in the protected static method?
this feels like we're missing an abstraction here.
One instanceof is red-flag, two indicates we're likely missing something.
e.g. a method getTopLevelDocumentData or getData or similar on an interface that all three of them implement
for $data it would return $this, for ResourceObjectData, it would return ::getAccessible, and for relationship, it would replace ::getData
we'd then typehint on that interface
the same interface could also have mergeLinks and mergeMetadata methods, offloading the burden of knowing 'how do I merge a N object' to those objects themselves, instead of here.
Do we expect the user to call the factory method here? If so should we make this protected?
This is concerning, can we not prevent the object from getting into an invalid state?
I.e any reason not to make the context argument required?
nit: generate one
I don't think we can just wholesale remove this, it needs to be deprecated, but we've had that discussion before on two other issues and I'll defer to the release managers
these is the only other places I see we call the factory method, and in both cases we use ->withContext immediately, can you elaborate why context is optional in the constructor?
Comment #37
larowlanTagging for RM review for the BC question
Comment #38
gabesullice1. This was nearly a copy and paste of the now removed static method
EntityReferenceFieldNormalizer::getRelationshipLinksmethod. The biggest difference was a new$field->getEntity()call. For that, I might have agreed that profiling could be useful. However, I changed the first argument from aResourceTypeto aResourceObjectin response to #36.5 and now I think there's really no significant change betweenEntityReferenceFieldNormalizer::getRelationshipLinksand this method. Do you still think it's necessary?2. Ooh, I like that suggestion!
I implemented that everywhere I could. I did not implement it in
LinkCollectionbecause that assertion isn't related to the context being top-level data or not. It's saying, "a link collection can exist as part of A, B, or C", and there's no interface that ought to be shared between them that I can suss out.3. 👍✅
4. 😮 After all these years, I had no clue one could make a constructor protected! Yes. We should do that. Done.
5. This design made more sense prior to #29. I've gotten rid of
withContext.6. 🦅👁.
7. Fair enough. Since you didn't mention it, did you happen to see #32.1?
My argument against keeping it around is this:
In the 1.x version of the module, we made this change: JSON API normalizers are removed from the serialization service. (We changed the normalizer service tag to (
jsonapi_normalizer_do_not_use_removal_imminent)Then, in 2.x (the version that was added to core), we added this to the JSON:API serializer:
I'm curious to understand your opinion about removing this class, given that history. Ofc, I'm still happy to have a RM weigh in too.
8. No longer relevant since 5 above.
To answer your question though, this made more sense prior to #29. Before that change, a
ResourceObjectwas constructed with a$fieldsarray that included this newRelationshipobject. In that constructor, the resource object calledwithContext($this)in order to add itself as the context of the relationship. In essence, this weird pattern came from a chicken and egg problem. Either construct theResourceObjectbefore theRelationshipobject and callwithContextor construct theRelationshipobject before theResourceObjectand callwithRelationship; both are a little strange.Comment #39
wim leers#36.4: PROTECTED CONSTRUCTORS!???!!!! 🤯🤯🤯🤯🤯🤯🤯🤯🤯 Like @gabesullice, I had no idea!
This is so much better now! 👏 Great suggestion, @larowlan 😃
Nice wins here, again thanks to @larowlan's suggestion! 👍
👎 These additions claim to inherit docs because they presumably implement an interface. But they don't.
AFAICT these are accidental additions that can be deleted?
Comment #40
gabesullice#39.3: Whoops, you're right! Removed.
Comment #41
wim leersI think we need to take another careful look at
TopLevelDataInterface.It was added in response to @larowlan's review. I like how it simplifies things. But. I am concerned about the set of classes that are implementing this interface:
ResourceObjectDataOmittedDataIncludedDataNullIncludedDataRelationshipThe latter seems … off. Especially because
Relationshipcontains aRelationshipDataobject. Based on the apparent symmetry that is present for the other classes implementing this interface, I would expectRelationshipDatato implement this interface and notRelationship.Is this because
RelationshipDatais misnamed and it should've been calledRelationshipObjectandRelationshipshould've been calledRelationshipData?Comment #42
gabesulliceNo. In the spec, a relationship object contains a relationship data member.
The reason there is a disconnect between it and all the other classes is because a relationship data is the only thing that can appear both at the top-level and nested within a resource object as well. IOW,
RelationshipDatais the only object that comes in a container.The interface abstracts this container away.
Comment #43
wim leers#42 is a crystal-clear answer 👍
RTBC'ing, but can you please update the issue summary ASAP? I explained in #24 why this is necessary.
Comment #44
gabesulliceI updated the IS again. However, see #29 and #32 for why I was not feeling particularly pressured to do so.
Comment #45
catchIf we didn't have code specifically preventing more normalizers from being added I would agree we should keep the class around deprecated, even if it was likely to be dead code. But given there's no way for contrib to use it this should be zero impact, and it's not got any other uses. Untagging. See also comment here for longer explanation https://www.drupal.org/project/drupal/issues/3014277#comment-13239653
Did not do an in-depth patch review.
Comment #46
wim leersCompletely agreed!
Comment #47
alexpottNeeds reroll.
Comment #48
spokjeReroll
Comment #49
spokjeRestoring tags
Comment #51
spokjeDo a reroll, they said.
It wil be easy, they said.
Comment #52
spokjeBack to RTBC after re-roll
Comment #53
gabesullice@Spokje++. Thank you!
Comment #54
spokje@gabesullice no problem,but you might wanna change the "re-tests daily" to #51 instead of #48, because I screwed that last one up badly. #51 is where the rainbows and unicorns are.
Comment #55
larowlanThanks for accommodating my suggestions, I think this is looking pretty tidy now.
are we sure there's no way to get rid of these instanceof checks?
It feels like we're missing two factory methods on this class.
::fromTopLevelData and ::fromErrorCollection
And then the constructor can be protected
Like I said, instanceof is always a red flag in my book (but hey, so is else and elseif, what can I say, I'm peculiar)
Can we comment here that this is protected by design and add a @see to the factory method?
Comment #56
larowlanfeels like that should be on a tshirt 😂
Comment #57
gabesullice1. I like this suggestion, but I think it's out of scope for this issue. It'll require some BC limbo too. I added a followup for it here: #3083179: Follow-up to #3036285: Deprecate JsonApiDocumentTopLevel::__construct in favor of two dedicated factory methods for data and error documents.
2. Sure! Done.
Given that the only change to the patch is limited to a docblock, I'm taking the liberty of moving this back to RTBC.
Comment #58
xjmBumping to major since this is a contrib blocker. Thanks!
Comment #59
larowlanCrediting myself here as my review changed the patch somewhat.
Crediting @catch for an RM review, as those are invaluable.
Comment #60
larowlanCan we get a change-record here - yes there's no BC layer and the changes are internal, but if someone has gone around that, a change-record might help them resolve the issue when they're searching after 8.8 comes out to work out why their thing no longer works
Please put straight back to RTBC, I will keep an eye out for the issue.
Comment #61
gabesulliceCR created: https://www.drupal.org/node/3084710
Thanks @larowlan!
Comment #62
gabesulliceComment #63
larowlanCommitted 7af2d3d and pushed to 8.8.x. Thanks!
Thanks for persisting on this one 🎉
Published the change record
Comment #65
gabesullice🎉🎉🎉🎉
Comment #66
xjmI think this doesn't need to go in the release notes for the same reason we didn't bother with BC in the first place, as described in #45. The CR should be sufficient.