#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.

Comments

gabesullice created an issue. See original summary.

gabesullice’s picture

Issue summary: View changes

Fixed a method signature.

gabesullice’s picture

Issue summary: View changes

https://tools.ietf.org/html/rfc8288#section-3.3 permits multiple link relation types per link.

gabesullice’s picture

StatusFileSize
new10.32 KB

This 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.

gabesullice’s picture

Title: Add a "LinkSubscriber" concept » Add a WebLink and LinkCollection class to support RFC8288 web linking.

This 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.

gabesullice’s picture

Status: Active » Needs review
StatusFileSize
new37.74 KB

Here's an initial implementation.

Might fail plenty of tests.

Status: Needs review » Needs work

The last submitted patch, 6: 2995960-6.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new48.08 KB

The 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.

Status: Needs review » Needs work

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

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new10.35 KB
new58.43 KB

This fixes the Request object argument passed into the EntityResource methods in EntityResourceTest. The request object didn't previously need a valid URI, but changes to the LinkManager mean s that it's now needed.

Status: Needs review » Needs work

The last submitted patch, 10: 2995960-10.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.7 KB
new58.97 KB

Two small fixes:

  1. The last patch used the wrong namespace for a ParameterBag.
  2. JsonApiDocumentTopLevelNormalizerTest was stubbing a method which no longer exists and is no longer used.

Status: Needs review » Needs work

The last submitted patch, 12: 2995960-12.patch, failed testing. View results

gabesullice’s picture

LinkManagerTest needs its mocks to be updated and to expect a LinkCollection to be returned from getPagerLinks instead 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.

gabesullice’s picture

StatusFileSize
new4.07 KB
new60.73 KB

Whoops, lost the files.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new726 bytes
new60.74 KB

Here's the very last bug fix. I forgot a parameter, probably because of a copy/paste.

This should be green.

Self-review next.

The last submitted patch, 15: 2995960-14.patch, failed testing. View results

gabesullice’s picture

Assigned: gabesullice » Unassigned
StatusFileSize
new3.17 KB
new61.12 KB
  1. +++ b/jsonapi.services.yml
    @@ -85,6 +85,14 @@ services:
    +  serializer.normalizer.link_collection.jsonapi:
    

    This is what I hope jsonapi_hypermedia will eventually be able to override in order to provide dynamic linking.

  2. +++ b/src/Controller/EntityResource.php
    @@ -824,7 +826,7 @@ class EntityResource {
    -   * @param string[] $links
    +   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
        *   The URLs to which to link. A 'self' link is added automatically.
    

    Just like other *Collection objects we've added, this makes for a more correct implementation. We can pass cacheability around with this too :). That makes handling Url::toString cacheability a lot simpler. You'll see one of many nice things next...

  3. +++ b/src/Controller/EntryPoint.php
    @@ -4,13 +4,13 @@ namespace Drupal\jsonapi\Controller;
    -use Drupal\Core\Render\RenderContext;
    -use Drupal\Core\Render\RendererInterface;
    
    @@ -31,13 +31,6 @@ class EntryPoint extends ControllerBase {
    -  /**
    -   * The renderer service.
    -   *
    -   * @var \Drupal\Core\Render\RendererInterface
    -   */
    -  protected $renderer;
    -
    
    @@ -50,14 +43,11 @@ class EntryPoint extends ControllerBase {
    -   * @param \Drupal\Core\Render\RendererInterface $renderer
    -   *   The renderer.
    ...
    -  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, RendererInterface $renderer, AccountInterface $user) {
    +  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, AccountInterface $user) {
    ...
    -    $this->renderer = $renderer;
    
    @@ -67,7 +57,6 @@ class EntryPoint extends ControllerBase {
    -      $container->get('renderer'),
    

    We no longer need renderer in the entry point because link cacheability is carried along with the LinkCollection inside
    of the WebLinks.

  4. +++ b/src/Controller/EntryPoint.php
    @@ -83,32 +72,22 @@ class EntryPoint extends ControllerBase {
    +        // @todo: implement an extension relation type to signal that this is a primary collection resource.
    +        $link_relation_types = [];
    +        return $carry->withLink($resource_type->getTypeName(), new WebLink(new CacheableMetadata(), $url, $link_relation_types));
    
    +++ b/src/JsonApiResource/WebLink.php
    @@ -0,0 +1,172 @@
    +    // @todo: uncomment the extra assertion below when JSON API begins to use its own extension relation types.
    +    assert(/* !empty($link_relation_types) && */Inspector::assertAllStrings($link_relation_types));
    

    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.

  5. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,136 @@
    +/**
    + * Contains a set of WebLink objects.
    + *
    + * @internal
    + */
    +class LinkCollection implements \IteratorAggregate {
    

    A LinkCollection object corresponds with a "links object" (plural) in the spec.

  6. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,136 @@
    +   * @var array|\Drupal\jsonapi\JsonApiResource\WebLink[]
    

    This should just be array.

  7. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,136 @@
    +   *   A key for the link. If the key already exists, the given link will either
    +   *   be merged with the existing link or both keys will have a unique string
    +   *   appended to them. The link will only be merged with an existing link if
    +   *   they share the same base key.
    

    Most of this comment should be moved to LinkCollectionNormalizer.

  8. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,136 @@
    +   *   A new LinkCollection with the given link inserted or merged the current
    

    s/merged/merged with/

  9. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,136 @@
    +      foreach ($merged[$key] as $index => $existing_link) {
    +        if (WebLink::compare($existing_link, $new_link) === 0) {
    +          $merged[$key][$index] = WebLink::merge($existing_link, $new_link);
    

    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.

  10. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,136 @@
    +   * Filters a link collection using the provided callback.
    

    s/link collection/LinkCollection/

  11. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,136 @@
    +   * Ensures that a link key is valid.
    ...
    +    return is_string($key) && !is_numeric($key) && strpos($key, ':') === FALSE;
    

    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.

  12. +++ b/src/JsonApiResource/WebLink.php
    @@ -0,0 +1,172 @@
    +/**
    + * Represents an RFC5988 based web link.
    ...
    +class WebLink implements CacheableDependencyInterface {
    

    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.

  13. +++ b/src/JsonApiResource/WebLink.php
    @@ -0,0 +1,172 @@
    +    $generated_url = $url->setAbsolute()->toString(TRUE);
    ...
    +    $this->setCacheability($cacheability->addCacheableDependency($generated_url));
    

    This is where we capture all Url's cacheable metadata.

  14. +++ b/src/LinkManager/LinkManager.php
    @@ -73,16 +75,16 @@ class LinkManager {
    -   * @return string
    +   * @return \Drupal\Core\Url
    ...
    -  public function getRequestLink(Request $request, $query = NULL) {
    +  public function getRequestUrl(Request $request, $query = NULL) {
    

    This helps us capture cacheable metadata.

  15. +++ b/src/Normalizer/LinkCollectionNormalizer.php
    @@ -0,0 +1,74 @@
    +  /**
    +   * A random string to use when hashing links.
    +   *
    +   * This string is unique per instance of a link collection, but always the
    +   * same within it. This means that link key hashes will be non-deterministic
    +   * for outside observers, but two links within the same collection will always
    +   * have the same hash value.
    +   *
    +   * This is not used for cryptographic purposes.
    +   *
    +   * @var string
    +   */
    +  protected $hashSalt;
    

    This logic was developed for meta.omissions links. When this lands, we should be able to convert those to a LinkCollection.

  16. +++ b/src/Normalizer/WebLinkNormalizer.php
    @@ -0,0 +1,40 @@
    +    // Omit 'meta.rel' if the link object's key there is one link relation type
    +    // and it is the same as the link object's key.
    +    if (count($link_relation_types) > 1 || ($primary_link_relation = reset($link_relation_types)) && $primary_link_relation !== $context['link_object_key']) {
    

    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.

  17. +++ b/tests/src/Functional/EntryPointTest.php
    @@ -0,0 +1,93 @@
    +class EntryPointTest extends BrowserTestBase {
    

    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 authenticated

  18. +++ b/tests/src/Functional/EntryPointTest.php
    @@ -0,0 +1,93 @@
    +  /**
    +   * Copied from \Drupal\Tests\jsonapi\Functional\ResourceTestBase.
    +   *
    +   * @see \Drupal\Tests\jsonapi\Functional\ResourceTestBase::request()
    +   */
    ...
    +  /**
    +   * Copied from \Drupal\Tests\jsonapi\Functional\ResourceTestBase.
    +   *
    +   * @see \Drupal\Tests\jsonapi\Functional\ResourceTestBase::decorateWithXdebugCookie()
    +   */
    

    Maybe these should be in a trait?


I've addressed all the things I mentioned above that weren't questions.

gabesullice’s picture

bump.

gabesullice’s picture

StatusFileSize
new60.43 KB

Reroll.

wim leers’s picture

High-level review.

I like this a lot. The value objects seem to fit beautifully and naturally simplify things.

  1. +++ b/jsonapi.services.yml
    @@ -84,6 +84,14 @@ services:
    +    class: Drupal\jsonapi\Normalizer\LinkCollectionNormalizer
    ...
    +    class: Drupal\jsonapi\Normalizer\WebLinkNormalizer
    

    "Link Collection" and "Web Link". But doesn't that mean it's "Web Link Collection"?

  2. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,134 @@
    +class LinkCollection implements \IteratorAggregate {
    
    +++ b/src/JsonApiResource/WebLink.php
    @@ -0,0 +1,172 @@
    +class WebLink implements CacheableDependencyInterface {
    

    Thinking out loud: LinkRelation and LinkRelations.

    (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).

  3. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,134 @@
    +   * @return static
    +   *   A new LinkCollection with the given link inserted or merged with the
    +   *   current set of links.
    +   */
    +  public function withLink($key, WebLink $new_link) {
    ...
    +   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
    +   *   A new, filtered LinkCollection.
    +   */
    +  public function filter(callable $f) {
    ...
    +   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
    +   *   A new LinkCollection with the links of both inputs.
    +   */
    +  public static function merge(LinkCollection $a, LinkCollection $b) {
    

    Immutable: 👍

  4. +++ b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
    @@ -173,12 +173,13 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm
    +    $links = $this->serializer->normalize($object->getLinks(), $format, $context);
    
    +++ b/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
    @@ -92,12 +91,13 @@ class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface,
    +  public function __construct($document_type, array $values, NormalizedValue $links, $cardinality = FALSE, $includes = FALSE, array $meta = []) {
    ...
    +    $this->links = $links;
    
    +++ b/src/Normalizer/Value/NormalizedValue.php
    @@ -0,0 +1,51 @@
    +class NormalizedValue implements ValueExtractorInterface, CacheableDependencyInterface {
    
    +++ b/src/Normalizer/WebLinkNormalizer.php
    @@ -0,0 +1,40 @@
    +class WebLinkNormalizer extends NormalizerBase {
    

    Why do we even need this? Why can't we pass LinkCollection straight to JsonApiDocumentTopLevelNormalizerValue and let \Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue::rasterizeValue() handle this?

  5. I expected — perhaps wrongly — \Drupal\jsonapi\LinkManager\LinkManager to 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 :)

gabesullice’s picture

#21.1-2: I went with WebLink to differentiate it from all the other Link like classes in Drupal core. But I can change it, that's what namespaces are for right?
3. :D
4. Because:

  1. I want to be able to alter links in the jsonapi_hypermedia by swapping out these normalizers.
  2. JsonApiDocumentTopLevelNormalizer(Value) are practically god classes as it is. I actually do like the tree like structure of the symfony serialization system with granular normalizers for different objects.
  3. I really don't like how our rasterizeValue methods contain lots of logic that should actually be in the normalizer, not the value object that it produces.
  4. A simple NormalizedValue object 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).
  5. It's closer to the interface that the normalize method 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 omissions array into a LinkCollection then reusing the normalizer mentioned above reduce complexity in JsonApiTopLevelDocumentNormalizerValue::rasterizeValue

gabesullice’s picture

Title: Add a WebLink and LinkCollection class to support RFC8288 web linking. » Add a Link and LinkCollection class to support RFC8288 web linking.
StatusFileSize
new15.17 KB
new60.33 KB

This renames WebLink to Link and WebLinkCollection to LinkCollection, 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?

Status: Needs review » Needs work

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

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new603 bytes
new60.33 KB

Fixes the CS violation.

e0ipso’s picture

Sorry I didn't finish the review. But sleep needs caught up w/ me.

I'm liking what I see so far.

  1. +++ b/src/Controller/EntityResource.php
    @@ -841,7 +843,7 @@ class EntityResource {
    -   * @param string[] $links
    +   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
    

    I just started and I like this already.

  2. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,172 @@
    +   *   A list of valid RFC5988 link relation types.
    

    Valid in what sense? Meaning already registered? If not, we should give an example of a custom relation type.

  3. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,172 @@
    +    assert(static::compare($a, $b) === 0);
    

    Should this be enforced outside of the asserts? Does it make sense to merge 2 links with a different href?

wim leers’s picture

#22.4.1: can't this jsonapi_hypermedia module alter links by taking a \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel object and creating a new one, with a different set of links?

gabesullice’s picture

@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_hypermedia will need to override all the associated normalizers, or, will have to be tightly coupled to JsonApiDocumentTopLevel in order to replace LinkCollection objects 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.

wim leers’s picture

The perspective I'm coming from:

  • I'd rather not introduce more normalizers
  • if we have Link value objects and JsonApiDocumentTopLevel is another value object that can contain Links, then in principle we shouldn't need much else
  • the code I quoted in #21.4 at least seemed to be doing much else — I admittedly did not yet do a deep dive to grok every nuance of this patch
gabesullice’s picture

the code I quoted in #21.4 at least seemed to be doing much else — I admittedly did not yet do a deep dive to grok every nuance of this patch

Ah, I guess I didn't ever make my medium-term intentions clear.

These LinkCollection instances 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.

gabesullice’s picture

StatusFileSize
new908 bytes

Patch 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?

gabesullice’s picture

StatusFileSize
new60.41 KB

Whoops!

wim leers’s picture

StatusFileSize
new3.35 KB
new60.85 KB
  1. +++ b/jsonapi.services.yml
    @@ -84,6 +84,14 @@ services:
    +    class: Drupal\jsonapi\Normalizer\LinkCollectionNormalizer
    ...
    +    class: Drupal\jsonapi\Normalizer\LinkNormalizer
    

    These are now consistent 👍

  2. +++ b/src/Controller/EntityResource.php
    @@ -849,8 +851,9 @@ class EntityResource {
    +    $self_link = new Link(new CacheableMetadata(), $this->linkManager->getRequestUrl($request), ['self']);
    +    $links = ($links ?: new LinkCollection([]))->withLink('self', $self_link);
    
    +++ b/src/LinkManager/LinkManager.php
    @@ -121,22 +124,26 @@ class LinkManager {
    +      $pager_links = $pager_links->withLink('next', new Link(new CacheableMetadata(), $next_url, ['next']));
    ...
    +        $pager_links = $pager_links->withLink('last', new Link(new CacheableMetadata(), $last_url, ['last']));
    ...
    +      $pager_links = $pager_links->withLink('first', new Link(new CacheableMetadata(), $first_url, ['first']));
    ...
    +      $pager_links = $pager_links->withLink('prev', new Link(new CacheableMetadata(), $prev_url, ['prev']));
    

    'self' is in here twice, once in the Link invocation, once in the withLink(…) invocation.

    Same for the other code hunks I cited.

  3. +++ b/src/Controller/EntryPoint.php
    @@ -83,32 +72,22 @@ class EntryPoint extends ControllerBase {
    +    $urls = array_reduce($resources, function (LinkCollection $carry, ResourceType $resource_type) {
    

    Nit: s/$urls/$links/

    (Fixed!)

  4. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -52,18 +50,13 @@ class JsonApiDocumentTopLevel {
    -  public function __construct($data, EntityCollection $includes, array $links, array $meta = []) {
    +  public function __construct($data, EntityCollection $includes, LinkCollection $links, array $meta = []) {
         assert(!$data instanceof ErrorCollection || $includes instanceof NullEntityCollection);
    -    assert(Inspector::assertAll(function ($link) {
    -      return is_array($link) || isset($link['href']) && is_string($link['href']);
    -    }, $links));
    

    🎉

  5. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,174 @@
    +   * JSON:API Link constructor.
    
    +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,134 @@
    +   * LinkCollection constructor.
    

    Nit: inconsistent.

    (Fixed!)

  6. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,174 @@
    +  public function __construct(CacheableMetadata $cacheability, Url $url, array $link_relation_types, array $target_attributes = []) {
    
    +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,134 @@
    +  public function __construct(array $links) {
    

    👌 These both look excellent!

  7. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,174 @@
    +   * Compares two links by their href.
    

    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!

  8. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,174 @@
    +  public static function merge(Link $a, Link $b) {
    

    Needs test coverage?

  9. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,134 @@
    +  protected static function validKey($key) {
    +    return is_string($key) && !is_numeric($key) && strpos($key, ':') === FALSE;
    +  }
    

    Where are these restrictions defined? Are we imposing them? Or do they originate from RFC8288?

  10. +++ b/src/LinkManager/LinkManager.php
    @@ -73,16 +75,16 @@ class LinkManager {
    -   * @return string
    +   * @return \Drupal\Core\Url
    ...
    -      return $request->getUri();
    +      return Url::fromUri($request->getUri());
    

    👌 This is completing the work I started doing a long time ago, yay!

  11. +++ b/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
    @@ -79,7 +78,7 @@ class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface,
    -   * @param string[] $links
    +   * @param \Drupal\jsonapi\Normalizer\Value\NormalizedValue $links
    

    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 NormalizedValue class until a future issue where there would be multiple things using it, instead of this single one?

  12. +++ b/src/Normalizer/LinkNormalizer.php
    @@ -0,0 +1,40 @@
    +  public function normalize($object, $format = NULL, array $context = []) {
    

    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.

  13. +++ b/src/Normalizer/Value/NormalizedValue.php
    @@ -0,0 +1,51 @@
    +  public function rasterizeIncludes() {
    +    return [];
    +  }
    

    Can be removed :)

    (Done!)

  14. +++ b/tests/src/Functional/EntryPointTest.php
    @@ -0,0 +1,93 @@
    +    $response = $this->request('GET', Url::fromUri('base://jsonapi'), [RequestOptions::HEADERS => ['Accept' => 'application/vnd.api+json']]);
    

    Nit base:// should be base:/.

    (Fixed!)

  15. +++ b/tests/src/Functional/EntryPointTest.php
    @@ -0,0 +1,93 @@
    +  /**
    +   * Copied from \Drupal\Tests\jsonapi\Functional\ResourceTestBase.
    +   *
    +   * @see \Drupal\Tests\jsonapi\Functional\ResourceTestBase::request()
    +   */
    +  protected function request($method, Url $url, array $request_options) {
    ...
    +  /**
    +   * Copied from \Drupal\Tests\jsonapi\Functional\ResourceTestBase.
    +   *
    +   * @see \Drupal\Tests\jsonapi\Functional\ResourceTestBase::decorateWithXdebugCookie()
    +   */
    +  protected function decorateWithXdebugCookie(array $request_options) {
    

    Can we instead move these two methods to a trait that both this test and ResourceTestBase use?

wim leers’s picture

Assigned: Unassigned » 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.

wim leers’s picture

I TOTALLY FORGOT ABOUT THIS I AM SO SORRY WILL REVIEW TOMORROW 😅

wim leers’s picture

Assigned: wim leers » Unassigned

I did take another look. I looked at https://cgit.drupalcode.org/jsonapi_hypermedia/commit/?id=a4477cf5af177c... and at JsonApiHypermediaLinkCollectionNormalizer in particular.

I can now see how having the normalizer for the link collection being separate is valuable; because it allows JsonApiHypermediaLinkCollectionNormalizer to do its thing, despite it being a private API, and jsonapi_hypermedia accepting the consequences of it hooking into a private API. Cool. 👍

But that in turn raised more questions 😅 🤓 Sorry 😃

I know jsonapi_hypermedia is just a PoC right now. But JsonApiHypermediaLinkCollectionNormalizer indicates — if I am understanding it correctly — that the only information that a module would be given is a LinkCollection value object. You can then return a new LinkCollection instance: 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 the link_object_key context, 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.

gabesullice’s picture

Assigned: Unassigned » gabesullice
StatusFileSize
new7.64 KB
new62.49 KB

But that in turn raised more questions 😅 🤓 Sorry 😃

This is a good thing, don't apologize!

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.

This was super insightful. I 100% thought I had already fixed this by passing the context object in the normalizer's $context argument, 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 LinkCollection can have is a JsonApiTopLevel object, but in future as it could be a Relationship object and once #3015438: Wrap entity objects in a ResourceObject which carries a ResourceType lands, a ResourceObject as well.

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.

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.

gabesullice’s picture

  1. +++ b/jsonapi.services.yml
    @@ -84,6 +84,14 @@ services:
    +  serializer.normalizer.web_link.jsonapi:
    +    class: Drupal\jsonapi\Normalizer\LinkNormalizer
    +    tags:
    +      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
    
    +++ b/src/Normalizer/LinkNormalizer.php
    @@ -0,0 +1,41 @@
    +class LinkNormalizer extends NormalizerBase {
    

    We need the collection normalizer for the reasons described in #36, but we really don't need LinkNormalizer.

  2. +++ b/src/Normalizer/LinkNormalizer.php
    @@ -0,0 +1,41 @@
    +    // Omit 'meta.rel' if the link object's key there is one link relation type
    +    // and it is the same as the link object's key.
    +    if (count($link_relation_types) > 1 || ($primary_link_relation = reset($link_relation_types)) && $primary_link_relation !== $context[LinkCollectionNormalizer::LINK_KEY]) {
    +      foreach ($link_relation_types as $link_relation_type) {
    +        $normalized['meta']['rel'][] = $link_relation_type;
    +      }
    +    }
    

    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 @todo issue and comment out the if link.

Status: Needs review » Needs work

The last submitted patch, 37: 2995960-37.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

Assigned: gabesullice » Unassigned
Status: Needs work » Needs review
StatusFileSize
new5.22 KB
new61.46 KB

Ready for review again.

Status: Needs review » Needs work

The last submitted patch, 40: 2995960-40.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new769 bytes
new61.46 KB

Derp.

wim leers’s picture

Context

I added a context property to LinkCollection. This is conceptually sound even absent of any idea about normalization since links have link contexts.

Good call.

But that RFC specifically says: the context of a link conveyed in the Link header field is the URL of the representation it is associated with. Whereas this patch is passing a JsonApiDocumentTopLevel object as the context.

To be fair, I omitted the two words preceding that quote: by default. 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\ResourceIdentifier object 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 your Relationship and ResourceObject mentions, but my concern is different: it's not about adding those, it's about providing JsonApiDocumentTopLevel at all.)

Still, I can't help but wonder why it would be insufficient for JsonApiHypermediaLinkCollectionNormalizer to inspect the existing self link 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 JsonApiDocumentTopLevel object's data removes the need for loading that resource.
But what if JsonApiDocumentTopLevel has 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 a enable link make sense when the status field is omitted? Or does it make sense when you're filtering a collection down to only the enabled resources?

Per-resource LinkCollection instead of a single top-level LinkCollection
In the current patch, it's only possible to customize the links for the primary data. But what about includes? For example, a blog post with included tags, or a cart with included cart items. We may want to perform operations on those included resources via hypermedia links.

It 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?

Consequences for partial caching
In #2819335: Resource (entity) normalization should use partial caching, we'll want to add partial caching. We need to make sure that the links added by jsonapi_hypermedia to 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, then jsonapi_hypermedia would need to allow to associate appropriate cacheability. If we're confident this is unneeded, then this should be explicitly documented.
Concrete examples
https://cgit.drupalcode.org/jsonapi_hypermedia/log/ does not list any commits to leverage #37. Seeing an example there of these various use cases would be helpful!
Aside: The links key in the @EntityType annotation.
This is a relative aside because this is less about the infrastructure that this issue is adding, but more about the jsonapi_hypermedia module's implementation. I'm wondering if instead of conjuring arbitrary links, it may be desirable to add additional links to the links annotation of entity types, and have those be state-dependent (if the state means the link does not make sense, that link would render to NULL). I'm well aware that this would require infrastructure changes in Drupal core. So what if we'd call this jsonapi_hypermedia_links initially (or even hypermedia_links) and then let jsonapi_hypermedia validate this, and if proven, it could be a non-breaking new feature in Drupal core.
gabesullice’s picture

#43:

[That] RFC specifically says: the context of a link conveyed in the Link header field is the URL of the representation it is associated with. Whereas this patch is passing a JsonApiDocumentTopLevel object as the context.

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).

That'd also prevent crazy complex link additions in jsonapi_hypermedia (i.e. based on some obscure detail in JsonApiDocumentTopLevel.... my concern is different: it's about providing JsonApiDocumentTopLevel at all.

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 self link 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-article with that as its self link and its resource object would have the self link /node/article/some-uuid-here. The next link 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: a published link (to provide an alternative filtered collection) or a recent link (which sorts by created) or a menu-item link (to power a frontend menu system).

What I don't think this will end up being is what you hint at next:

[What] if JsonApiDocumentTopLevel has a bunch of sparse fieldsets defined? That might mean you still will not be able to introspect the necessary data. For example, does an enable link make sense when the status field is omitted? Or does it make sense when you're filtering a collection down to only the enabled resources?

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 LinkCollection objects, but I want that to happen. This goes back to #30. I expect LinkCollections to exists and be normalized at different depths of the response. So you won't need to deeply inspect JsonApiDocumentTopLevel because 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!

We need to make sure that the links added by jsonapi_hypermedia to 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, then jsonapi_hypermedia would need to allow to associate appropriate cacheability. If we're confident this is unneeded, then this should be explicitly documented.

Again, I think this concern is mitigated by #30? Link objects implement CacheableDependencyInterface so I assume takes care of the rest of the concern, doesn't it?

Concrete examples

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_links initially (or even hypermedia_links) and then let jsonapi_hypermedia validate 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.

wim leers’s picture

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!

Yep :)

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!)

it then brings in the complexity of of cross-compatibility with REST module

Core REST does not introspect the links annotation, and certainly not some new annotation key.

gabesullice’s picture

Title: Add a Link and LinkCollection class to support RFC8288 web linking. » [PP-1] Add a Link and LinkCollection class to support RFC8288 web linking.
Status: Needs review » Postponed

Postponing this on #3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization, which adds the NormalizedValue object 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 :)

wim leers’s picture

Title: [PP-1] Add a Link and LinkCollection class to support RFC8288 web linking. » Add a Link and LinkCollection class to support RFC8288 web linking.
Assigned: Unassigned » gabesullice
Status: Postponed » Needs work
gabesullice’s picture

Assigned: gabesullice » Unassigned
Status: Needs work » Needs review
StatusFileSize
new4.37 KB
new55.08 KB

Rerolled. Not everything is in the interdiff, but what's not in there was not significant (just merge conflicts).

Status: Needs review » Needs work

The last submitted patch, 50: 2995960-50.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new2.59 KB
new56.83 KB
gabesullice’s picture

Assigned: Unassigned » wim leers

Looks good! I think this is ready for a final review?

wim leers’s picture

StatusFileSize
new4.52 KB
new57.17 KB

Yep!

  1. +++ b/src/Controller/EntryPoint.php
    @@ -84,32 +73,22 @@ class EntryPoint extends ControllerBase {
    +        $url = Url::fromRoute(sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $route_suffix))->setAbsolute();
    
    +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,174 @@
    +    $generated_url = $url->setAbsolute()->toString(TRUE);
    

    ✅ Nit: shouldn't the setAbsolute() happen just before normalizing/rendering? Or in Link? We don't want to depend on every piece of code setting this flag.

    EDIT: hah, this is already happening!

  2. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,174 @@
    +namespace Drupal\jsonapi\JsonApiResource;
    
    +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,175 @@
    +namespace Drupal\jsonapi\JsonApiResource;
    

    🤔 Übernit: is this the right namespace?

  3. +++ b/src/JsonApiResource/Link.php
    @@ -0,0 +1,174 @@
    +class Link implements CacheableDependencyInterface {
    
    +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,175 @@
    +class LinkCollection implements \IteratorAggregate {
    

    ✅ I think this should be final, at least for now. We can choose to open it up for extension later. Done.

  4. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,175 @@
    +   * @var array
    

    ✅ Übernit: could be more specific. Fixed.

  5. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -0,0 +1,175 @@
    +   * The link context.
    +   *
    +   * All links objects exist within a context object. Links form a relationship
    +   * between a source IRI and target IRI. A context is the link's source.
    

    ✅ Nit: should link to https://tools.ietf.org/html/rfc8288#section-3.2. Done.

  6. +++ b/src/LinkManager/LinkManager.php
    @@ -73,16 +75,16 @@ class LinkManager {
    -  public function getRequestLink(Request $request, $query = NULL) {
    +  public function getRequestUrl(Request $request, $query = NULL) {
    

    🤔 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 LinkManager away completely? I think that will mean one disruption instead of this tiny disruption with very little gain.

  7. 🤔 ✅ Related to previous point: I was shocked to see that \Drupal\jsonapi\LinkManager\LinkManager is currently only marked @deprecated and 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 making LinkManager a "do not use this" thing. It was marked @deprecated just 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 @internal without worry.
    EDIT: what confirms this impression definitively is @e0ipso's remark at #2933343-13: Define, document and mark scope of PHP and HTTP API:

    @gabesullice as noted in #2857730-10: Add a link to get only published entities in collections I think that true HATEOAS needs to populate links from within the application (it's not possible to do it from the module itself).

    The link manager is the service the actual sites will use to create those links properly.

    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:

    Yeah, @gabesullice is right about LinkManager. I was speaking about what I hoped it had become through iterations and support requests that never happened. That indicates that people don't care about links or HATEOAS, which makes me a bit sad. But it's a reality I embrace by making it an internal API.

    That is finally happening here and now!

    Given all that, I went ahead and marked it @internal.

  8. +++ b/src/Normalizer/LinkCollectionNormalizer.php
    @@ -0,0 +1,96 @@
    + * The JSON API specification has the concept of a "links collection". A links
    

    ✅ Übernit: s/JSON API/JSON:API/. Fixed.

  9. +++ b/src/Normalizer/LinkCollectionNormalizer.php
    @@ -0,0 +1,96 @@
    +   * This string is unique per instance of a link collection, but always the
    +   * same within it. This means that link key hashes will be non-deterministic
    +   * for outside observers, but two links within the same collection will always
    +   * have the same hash value.
    

    👌

  10. +++ b/src/Normalizer/LinkCollectionNormalizer.php
    @@ -0,0 +1,96 @@
    +        $normalized[$link_key] = new CacheableNormalization($link, $normalization);
    +      }
    +    }
    +    return CacheableNormalization::aggregate($normalized);
    

    👌

  11. +++ b/tests/src/Functional/EntryPointTest.php
    @@ -0,0 +1,90 @@
    +  protected static $modules = [
    +    'node',
    +    'jsonapi',
    +    'serialization',
    +    'system',
    +    'user',
    +  ];
    

    ✅ Nit: We don't list every dependency explicitly in functional tests. serialization is installed automatically when installing jsonapi, 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.

  12. +++ b/tests/src/Functional/EntryPointTest.php
    @@ -0,0 +1,90 @@
    +   * Copied from \Drupal\Tests\jsonapi\Functional\ResourceTestBase.
    ...
    +  protected function request($method, Url $url, array $request_options) {
    ...
    +   * Copied from \Drupal\Tests\jsonapi\Functional\ResourceTestBase.
    ...
    +  protected function decorateWithXdebugCookie(array $request_options) {
    

    🤔 Probably makes sense to add a JsonapiRequestTestTrait and use that in both?

wim leers’s picture

Status: Needs review » Needs work

The last submitted patch, 54: 2995960-54.patch, failed testing. View results

gabesullice’s picture

Assigned: gabesullice » Unassigned
Status: Needs work » Needs review
StatusFileSize
new14.16 KB
new61.36 KB

Fixes 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.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community
  1. Ahh, of course!👌
  2. 👍
  3. 👍
  4. 👍

@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.

wim leers’s picture

Status: Reviewed & tested by the community » Fixed

I 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 :)

The last submitted patch, 4: 2995960-4.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

wim leers’s picture

wim leers’s picture

wim leers’s picture

  • Wim Leers committed ea06332 on 8.x-2.x
    Issue #3027501 by Wim Leers: [regression] Follow-up for #2995960 and #...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.