#2994193: [META] The Last Link: A Hypermedia Story outlined the need and benefits to custom links between JSON API resources.
To sum it up, dynamic linking between resources is necessary to use hypermedia as the engine of application state.
Links can be added to all levels of a JSON API document. Those levels are:
- Document
- Resource Object
- Relationship Object
- Error Object
Links can vary per instance of each of those object types. IOW, a Resource Object A may have a link to resource Foo but RO B might have a link to Bar, or no link at all, even if A and B are of the same Resource Type.
The architecture I propose is an event subscriber pattern.
Subscribers will be able to listen for events by object type. They will receive a LinkEvent which will have the interface:
LinkEvent {
public getObjectType(): JsonApiSpec::(Document|Resource|Relationship|Error)Object
public getObject(): mixed
public addLink(WebLink $link)
public filterLinks(callable func (WebLink): bool): void
}
WebLink {
public getUrl(): \Drupal\Core\Url
public getLinkRelationTypes(): string[]
public getTargetAttributes(): string[]
}
At first, this should be an @internal API.
Within JSON API itself, our subscribers could handle: pagination, translations, revisions, create/edit/delete links, etc.
Outside of JSON API, once the API is made public, modules like commerce could add add-to-cart links to product resources. Or revision links could be moved to content_moderation. schemata could add a describedBy link to resource objects. The Admin UI initiative could add an edit-form relation to a resource providing form metadata.
| Comment | File | Size | Author |
|---|---|---|---|
| #57 | 2995960-57.patch | 61.36 KB | gabesullice |
| #57 | interdiff.txt | 14.16 KB | gabesullice |
| #54 | 2995960-54.patch | 57.17 KB | wim leers |
| #54 | interdiff.txt | 4.52 KB | wim leers |
| #52 | 2995960-52.patch | 56.83 KB | wim leers |
Comments
Comment #2
gabesulliceFixed a method signature.
Comment #3
gabesullicehttps://tools.ietf.org/html/rfc8288#section-3.3 permits multiple link relation types per link.
Comment #4
gabesulliceThis is the start of a sketch of what I outlined in the IS.
The event would be fired in the corresponding normalizer (not normalizer value) for each of the 4 primary objects (document, resource, error, relationship). The resulting LinkCollection would then be passed as an argument to the normalizer value or serialized on its own. Not sure yet.
Comment #5
gabesulliceThis was discussed in chat and then summarized in #2994193-15: [META] The Last Link: A Hypermedia Story.
The gist is that the event dispatch should not live in JSON API itself, but the fundamental concept of a link and link collection should.
Comment #6
gabesulliceHere's an initial implementation.
Might fail plenty of tests.
Comment #8
gabesulliceThe first two failing tests had to do with the entry point. This converts the EntryPoint to use WebLinks. Because WebLinks can carry cacheability metadata, we don't need to generate the links in a render context!
To make testing that cacheability easier, the EntryPoint Kernel test was also converted to a Functional test.
Comment #10
gabesulliceThis fixes the
Requestobject argument passed into theEntityResourcemethods inEntityResourceTest. The request object didn't previously need a valid URI, but changes to theLinkManagermean s that it's now needed.Comment #12
gabesulliceTwo small fixes:
ParameterBag.JsonApiDocumentTopLevelNormalizerTestwas stubbing a method which no longer exists and is no longer used.Comment #14
gabesulliceLinkManagerTestneeds its mocks to be updated and to expect aLinkCollectionto be returned fromgetPagerLinksinstead of an array of hrefs.There's one very small bug left in the actual implementation. I'll post that next so it's not lost in this interdiff.
Comment #15
gabesulliceWhoops, lost the files.
Comment #16
gabesulliceHere's the very last bug fix. I forgot a parameter, probably because of a copy/paste.
This should be green.
Self-review next.
Comment #18
gabesulliceThis is what I hope
jsonapi_hypermediawill eventually be able to override in order to provide dynamic linking.Just like other
*Collectionobjects we've added, this makes for a more correct implementation. We can pass cacheability around with this too :). That makes handlingUrl::toStringcacheability a lot simpler. You'll see one of many nice things next...We no longer need
rendererin the entry point because link cacheability is carried along with theLinkCollectioninsideof the
WebLinks.Ideally, I'd like all links to have a link relation type. Unfortunately, I think that's out of scope in this issue. We'll need to define our own extension relation types to do that.
A
LinkCollectionobject corresponds with a "links object" (plural) in the spec.This should just be
array.Most of this comment should be moved to
LinkCollectionNormalizer.s/merged/merged with/
When links have the same key and href, we merge them together. That means a link might have more than one link relation type a combined set of attributes. This keeps document size down.
s/link collection/LinkCollection/
A links object key should not be numeric and must not contain a colon (:). We use a colon and a unique string to differentiate multiple link with the same key. Disallowing colons will make it easier for HTTP clients to parse those keys.
RFC5988 was obsoleted by https://tools.ietf.org/html/rfc8288.
What makes this an "RFC8288 based link"?
It means that the link has a URI component, a "rel" component for link relations associated with that URI and an uncoordinated set of attributes.
This is where we capture all
Url's cacheable metadata.This helps us capture cacheable metadata.
This logic was developed for
meta.omissionslinks. When this lands, we should be able to convert those to aLinkCollection.This keeps normalizations much less verbose. But does make a client implementation a tiny bit more complicated. I think it's a good trade-off.
This got converted to a
FunctionalTest. See reasoning in #8.It'd be nice to test the "me" link here.
I don't remember if that's tested elsewhere.I investigated this an #2927037: Provide a mechanism to get information about the current user: "me" meta link in /jsonapi, and make /jsonapi accessible to all did not introduce any coverage to the logged in case. I created an issue: #3002646: Follow-up to #2927037: Add test coverage for `meta.links.me` when a user is authenticatedMaybe these should be in a trait?
I've addressed all the things I mentioned above that weren't questions.
Comment #19
gabesullicebump.
Comment #20
gabesulliceReroll.
Comment #21
wim leersHigh-level review.
I like this a lot. The value objects seem to fit beautifully and naturally simplify things.
"Link Collection" and "Web Link". But doesn't that mean it's "Web Link Collection"?
Thinking out loud:
LinkRelationandLinkRelations.(RFC8288 does not define "web link" as a concept. It's merely titled "web linking" as in "linking on the web". Otherwise I'd +1
WebLink).Immutable: 👍
Why do we even need this? Why can't we pass
LinkCollectionstraight toJsonApiDocumentTopLevelNormalizerValueand let\Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue::rasterizeValue()handle this?\Drupal\jsonapi\LinkManager\LinkManagerto become a lot simpler. Why is my expectation wrong?In any case: 👏👏👏👏 for this first iteration, this is looking fantastic already, and more importantly: it looks promising :)
Comment #22
gabesullice#21.1-2: I went with
WebLinkto differentiate it from all the otherLinklike classes in Drupal core. But I can change it, that's what namespaces are for right?3. :D
4. Because:
jsonapi_hypermediaby swapping out these normalizers.rasterizeValuemethods contain lots of logic that should actually be in the normalizer, not the value object that it produces.NormalizedValueobject is all one needs to move cacheability around with a normalized value (an array). (In future, I want to migrate things to use this class and have it implement \ArrayAccess in addition to validating it against a schema).normalizemethod should have. IOW, it's future compatible with the direction that I want to move our normalizers in.5. That's because this patch does not attempt to change how links are generated, only what the generated links are. A simplification to look forward to will be turning the normalization of the
omissionsarray into aLinkCollectionthen reusing the normalizer mentioned above reduce complexity inJsonApiTopLevelDocumentNormalizerValue::rasterizeValueComment #23
gabesulliceThis renames
WebLinktoLinkandWebLinkCollectiontoLinkCollection, per #21.1.That means everything from #21 has been addressed or answered. @Wim Leers or @e0ipso, can one of you do a final review?
Comment #25
gabesulliceFixes the CS violation.
Comment #26
e0ipsoSorry I didn't finish the review. But sleep needs caught up w/ me.
I'm liking what I see so far.
I just started and I like this already.
Valid in what sense? Meaning already registered? If not, we should give an example of a custom relation type.
Should this be enforced outside of the asserts? Does it make sense to merge 2 links with a different href?
Comment #27
wim leers#22.4.1: can't this
jsonapi_hypermediamodule alter links by taking a\Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevelobject and creating a new one, with a different set of links?Comment #28
gabesullice@e0ipso, I'm gonna address your review in a minute.
@Wim Leers, I'm not sure that's really ideal. Links objects live at all levels of the document: in relationship objects, resource objects, error objects, etc. Taking that approach, then
jsonapi_hypermediawill need to override all the associated normalizers, or, will have to be tightly coupled toJsonApiDocumentTopLevelin order to replaceLinkCollectionobjects throughout its child values.Can you tell me what you think we lose or gain? I'm not sure what perspective you're coming from.
Comment #29
wim leersThe perspective I'm coming from:
Linkvalue objects andJsonApiDocumentTopLevelis another value object that can containLinks, then in principle we shouldn't need much elseComment #30
gabesulliceAh, I guess I didn't ever make my medium-term intentions clear.
These
LinkCollectioninstances will eventually be used for omissions, for links on entities, for links in error objects, for links on relationships objects etc. Not just the top-level links. That's just the simplest case I could find to have an actual use for them.I planned on adding followups for all those other value objects. Perhaps you think this patch should just convert all links in all places in one go? That's would be a very large patch I think.
Comment #31
gabesullicePatch is rebased.
Interdiff addresses @e0ipso's review.
#26:
1. 👍
2. I intentionally left this a little ambiguous. Technically, a valid link relation type is either registered or it's an "extension" relations type, which must be a valid URI. However, I think a lot of applications just make up their ow (because they know that the API will only ever be used internally, for example). So, by being ambiguous I was trying defer to the user to "do the right thing" by not enforcing anything too strictly. Do you think we should?
3. It never makes sense to merge links with different hrefs. What would it mean to do this outside an assert? Do you mean it should throw an exception? I preferred this because it fails softly in production rather than blowing up. Thoughts?
Comment #32
gabesulliceWhoops!
Comment #33
wim leersThese are now consistent 👍
'self'is in here twice, once in theLinkinvocation, once in thewithLink(…)invocation.Same for the other code hunks I cited.
Nit: s/$urls/$links/
(Fixed!)
🎉
Nit: inconsistent.
(Fixed!)
👌 These both look excellent!
I'm missing some documentation explaining why this is sufficient; why is there no need to include target attributes in the comparison?
EDIT: oh, only later I realized that this is not intended as a callback for sorting. Never mind, this is fine!
Needs test coverage?
Where are these restrictions defined? Are we imposing them? Or do they originate from RFC8288?
👌 This is completing the work I started doing a long time ago, yay!
I still find this the most confusing thing about this patch. I already brought it up in #21.4. I don't want to rehash it. I'm wondering if we can hold off on introducing this
NormalizedValueclass until a future issue where there would be multiple things using it, instead of this single one?I could imagine this just living in
JsonApiDocumentTopLevelNormalizerVale?That'd avoid creating a new normalizer, because we've been trying to move away from those.
Can be removed :)
(Done!)
Nit
base://should bebase:/.(Fixed!)
Can we instead move these two methods to a trait that both this test and
ResourceTestBaseuse?Comment #34
wim leers@gabesullice just pinged me, pointing out that wrt points 11+12, https://cgit.drupalcode.org/jsonapi_hypermedia/commit/?id=a4477cf5af177c... is going to be helpful in understanding this approach. I'll need to take another look.
Comment #35
wim leersI TOTALLY FORGOT ABOUT THIS I AM SO SORRY WILL REVIEW TOMORROW 😅
Comment #36
wim leersI did take another look. I looked at https://cgit.drupalcode.org/jsonapi_hypermedia/commit/?id=a4477cf5af177c... and at
JsonApiHypermediaLinkCollectionNormalizerin particular.I can now see how having the normalizer for the link collection being separate is valuable; because it allows
JsonApiHypermediaLinkCollectionNormalizerto do its thing, despite it being a private API, andjsonapi_hypermediaaccepting the consequences of it hooking into a private API. Cool. 👍But that in turn raised more questions 😅 🤓 Sorry 😃
I know
jsonapi_hypermediais just a PoC right now. ButJsonApiHypermediaLinkCollectionNormalizerindicates — if I am understanding it correctly — that the only information that a module would be given is aLinkCollectionvalue object. You can then return a newLinkCollectioninstance: one with additional links. Makes sense, but AFAICT the tricky thing then is: how does a hypermedia link providing module then determine which link collections it wants to add links to? There's no context whatsoever about which resource you're being offered the opportunity to generate additional links for. Well, there's thelink_object_keycontext, but that's just a hashed href.Perhaps the best way to move this forward is to have a joint call with all maintainers so you can explain this in more detail? I may very well be the only one for who it doesn't "click" how this would work.
Comment #37
gabesulliceThis is a good thing, don't apologize!
This was super insightful. I 100% thought I had already fixed this by passing the context object in the normalizer's
$contextargument, but I see now that I didn't!I was about to do what I just described, but then I realized that it required the normalizer to know what its child normalizer needs and creates a not-so-obvious coupling. Instead, I added a context property to
LinkCollection. This is conceptually sound even absent of any idea about normalization since links have link contexts.Right now, the only type of context that a
LinkCollectioncan have is aJsonApiTopLevelobject, but in future as it could be aRelationshipobject and once #3015438: Wrap entity objects in a ResourceObject which carries a ResourceType lands, aResourceObjectas well.I'm still happy to do that, but I think you just spotted a flaw and the fact that you caught it indicates to me that you do understand this now.
Assigning back to myself, I want to make a few improvements that I spotted.
Comment #38
gabesulliceWe need the collection normalizer for the reasons described in #36, but we really don't need
LinkNormalizer.I think we need a profile or real support in the spec for link relations before we go off on our own and decide a link rel goes under
meta.I can just create an
@todoissue and comment out theiflink.Comment #40
gabesulliceReady for review again.
Comment #42
gabesulliceDerp.
Comment #43
wim leersGood call.
But that RFC specifically says: . Whereas this patch is passing a
JsonApiDocumentTopLevelobject as the context.To be fair, I omitted the two words preceding that quote: . So it's probably fair to pass something else. Still, I wonder if it wouldn't be more appropriate to pass the associated JSON:API URL. Or at least just a
\Drupal\jsonapi\JsonApiResource\ResourceIdentifierobject rather than a completely assembled response document. That'd also prevent crazy complex link additions in https://www.drupal.org/project/jsonapi_hypermedia (i.e. based on some obscure detail in\Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel. (I know you're alluding to this with yourRelationshipandResourceObjectmentions, but my concern is different: it's not about adding those, it's about providingJsonApiDocumentTopLevelat all.)Still, I can't help but wonder why it would be insufficient for
JsonApiHypermediaLinkCollectionNormalizerto inspect the existingselflink and based on what that contains (e.g. which resource type it links to) add some additional links.I suppose the primary reason for not doing that would be that the whole point is that links are state-dependent. Having merely a URL as context does not allow easy resource state introspection, meaning that you'd need to load the resource (entity) associated with that URL to even figure out which links to add. Being able to access the
JsonApiDocumentTopLevelobject's data removes the need for loading that resource.But what if
JsonApiDocumentTopLevelhas a bunch of sparse fieldsets defined? That might mean you still will not be able to introspect the necessary data. Which actually leads to another question: does it make sense to add all potential links when sparsely retrieving a resource? For example, does aenablelink make sense when thestatusfield is omitted? Or does it make sense when you're filtering a collection down to only the enabled resources?LinkCollectioninstead of a single top-levelLinkCollectionIt would be a confusing experience to see those hypermedia links only when accessing the individual resources.
Actually, we don't even need to look at the includes case; there's a simpler example: collections. I don't think we want only to be able to have hypermedia links for the overall collection, not for the resources in that collection?
jsonapi_hypermediato a specific resource are dependent only on that particular resource; then they will get updated (invalidated) correctly: in sync with the resource itself. But if those links start depending on state of other resources, thenjsonapi_hypermediawould need to allow to associate appropriate cacheability. If we're confident this is unneeded, then this should be explicitly documented.linkskey in the@EntityTypeannotation.jsonapi_hypermediamodule's implementation. I'm wondering if instead of conjuring arbitrary links, it may be desirable to add additional links to thelinksannotation of entity types, and have those be state-dependent (if the state means the link does not make sense, that link would render toNULL). I'm well aware that this would require infrastructure changes in Drupal core. So what if we'd call thisjsonapi_hypermedia_linksinitially (or evenhypermedia_links) and then letjsonapi_hypermediavalidate this, and if proven, it could be a non-breaking new feature in Drupal core.Comment #44
gabesullice#43:
A URI is just a resource identifier (or subsection of that resource if the URI includes an anchor). I don't see a problem with passing the resource identified by the URI instead of the URI itself (especially as an API for things generating the links themselves).
First, why would I was to prevent crazy complex link additions? I think people are going to add the links they need. If those links are complex, it's probably removing that complexity from people's consuming applications (and also to benefit from caching that complexity). That's kind of the hypermedia dream, really. Sure, implementors will need to realize that there are tradeoffs WRT to performance, but that's why this thing exists in a different module (which, let's not forget, is extending an internal API and will not be stabilized until there's some solid real-world usage proving it).
Second, if a URI identifies a thing, then the thing being identified by the request URL is the top-level object. The top-level object contains things, like resource objects and relationship objects. Those child objects have their own context URIs. A question that I asked myself a while ago (and I think led to a deeper appreciation of how everything fits together in the spec) was this: "on an individual route, why is there a
selflink on the the top-level object and the primary resource object? And why does the spec not require them to be the same?"They're not the same because a completely valid JSON:API resource could be
/trending-articlewith that as itsselflink and its resource object would have theselflink/node/article/some-uuid-here. Thenextlink on the top-level object could be/trending-article?filter[uuid][value]=some-uuid-here&filter[uuid][operator]=<>. That hints at what I think the top-level links will end up being: apublishedlink (to provide an alternative filtered collection) or arecentlink (which sorts bycreated) or amenu-itemlink (to power a frontend menu system).What I don't think this will end up being is what you hint at next:
I don't think we need to worry too much about deep introspection. What you're talking about is links to be provided on a resource object, not the top-level object. No one should care about an enable link on the top-level because it belongs a level deeper, on the resource object to be enabled.
This patch does not yet convert resource object links to use
LinkCollectionobjects, but I want that to happen. This goes back to #30. I expectLinkCollectionsto exists and be normalized at different depths of the response. So you won't need to deeply inspectJsonApiDocumentTopLevelbecause you'll be given the lower level objects.It seems you feel like what is here is too blunt for all links in a response and it is because not everything is yet converted to use
LinkCollections, so you don't have something granular enough to cleanly handle per-resource links.By leaving those lower levels out of the patch, I was trying to keep this patch smaller, but perhaps it's too confusing to not see the whole scope of things in one place? ... I think the answer is yes, its confusing, because that's what the whole next section of your response is all about!
Again, I think this concern is mitigated by #30?
Linkobjects implementCacheableDependencyInterfaceso I assume takes care of the rest of the concern, doesn't it?There are not concrete examples of the things you're talking about because those lower-level things aren't yet converted to use
LinkCollections. I wouldn't want to do the things you suggest without #30.what if we'd call this <code>jsonapi_hypermedia_linksinitially (or evenhypermedia_links) and then letjsonapi_hypermediavalidate this, and if proven, it could be a non-breaking new feature in Drupal core.I don't fully see where you're going with this. I'm not entirely opposed to this, but my feeling was that the barrier to entry of using annotation links was too high and it then brings in the complexity of of cross-compatibility with REST module. I'd like to learn more about what you're thinking of for this.
Comment #45
wim leersRelated: #2960766-25: Support ResourceType which may have a null bundle defined..
Comment #46
wim leersYep :)
It'd have helped if this was explicitly called out in the title/issue summary. Because the issue summary even explicitly says they may exist on 4 levels.
I definitely didn't realize that/skipped over tat detail when I read #30 some time ago. (My bad!)
Core REST does not introspect the
linksannotation, and certainly not some new annotation key.Comment #47
gabesullicePostponing this on #3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization, which adds the
NormalizedValueobject suggested by #33.11. I didn't realize this issue would take so long then, but now the other issue is pretty much already finished :)Comment #48
gabesulliceComment #49
wim leers#3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization landed!
Comment #50
gabesulliceRerolled. Not everything is in the interdiff, but what's not in there was not significant (just merge conflicts).
Comment #52
wim leersComment #53
gabesulliceLooks good! I think this is ready for a final review?
Comment #54
wim leersYep!
✅ Nit: shouldn't the
setAbsolute()happen just before normalizing/rendering? Or inLink? We don't want to depend on every piece of code setting this flag.EDIT: hah, this is already happening!
🤔 Übernit: is this the right namespace?
✅ I think this should be
final, at least for now. We can choose to open it up for extension later. Done.✅ Übernit: could be more specific. Fixed.
✅ Nit: should link to https://tools.ietf.org/html/rfc8288#section-3.2. Done.
🤔 This rename will break
\Drupal\Tests\jsonapi_extras\Kernel\EntityToJsonApiTest.You're right that this is returning a URL and not a link. But that was already the case.
Furthermore, I think with the foundation that this patch is laying, it will probably make more sense to refactor
LinkManageraway completely? I think that will mean one disruption instead of this tiny disruption with very little gain.🤔✅ Related to previous point: I was shocked to see that\Drupal\jsonapi\LinkManager\LinkManageris currently only marked@deprecatedand not yet@internal. Let's do that here/now? That is in scope IMHO, because this is laying the foundation for the future, and is hence definitively makingLinkManagera "do not use this" thing. It was marked@deprecatedjust over a year ago, in #2933343: Define, document and mark scope of PHP and HTTP API. We've come a very long way since then. I think we now can mark it@internalwithout worry.EDIT: what confirms this impression definitively is @e0ipso's remark at #2933343-13: Define, document and mark scope of PHP and HTTP API:
That's exactly what this issue is working on :)
Oh and further down, at #2933343-17: Define, document and mark scope of PHP and HTTP API, @e0ipso wrote:
That is finally happening here and now!
Given all that, I went ahead and marked it
@internal.✅ Übernit: s/JSON API/JSON:API/. Fixed.
👌
👌
✅ Nit: We don't list every dependency explicitly in functional tests.
serializationis installed automatically when installingjsonapi, for example.EDIT: oh, that's just a leftover from converting this from a
KernelTestBase. Cool, then this is the slight simplification we get for free.🤔 Probably makes sense to add a
JsonapiRequestTestTraitandusethat in both?Comment #55
wim leersPoints 2, 6 and 12 still need Gabe's input.
Comment #57
gabesulliceFixes the CS violation, another nit, then does 6 and 12.
2. Yes, these objects map to "link object" and "links object" in the spec.
6. Fair enough, I'll undo this.
7. Makes sense.
12. Sure.
Comment #58
wim leers@e0ipso was an explicit supporter of the overall plan in #2994193: [META] The Last Link: A Hypermedia Story and a long time ago in at #2933343-13: Define, document and mark scope of PHP and HTTP API and #2933343-17: Define, document and mark scope of PHP and HTTP API.
Comment #59
wim leersI think I remember from our last meeting that @e0ipso said he was happy with the state of this patch, but I'm not 100% certain. @e0ipso last commented on November 8, in #26, and I know that he's said before that if he doesn't comment for a long time, it usually means he's +1 :)
So, for the sake of progress, let's do this — I'm happy to take the blame and revert if you still had concerns, @e0ipso :)
Comment #62
wim leersThis unblocked #3002646: Follow-up to #2927037: Add test coverage for `meta.links.me` when a user is authenticated and #3014704: Expose API to allow links handling for entities from other modules.
Comment #63
wim leersOh, this also unblocked #2992836: Provide links to resource versions (entity revisions)!
Comment #64
wim leersThis unfortunately also caused a regression on PHP 5.6: #3027501: [regression] Follow-up for #2995960 and #2992833: syntax errors in PHP 5.5 & 5.6.