Customizing Relationship Links with JSON API

Last reviewed in June 2019 with Ember Octane

Not all APIs are in line with Ember Data's adapters and serializers “standard” or “out of the box” behavior.

How do we deal with nested or filtered resources such as /post/1/comments or /api/branches?bankId=322&list=true?

Today we'll override JSONAPISerializer and use the links property to load hasMany relationships from custom endpoints.

Example URL #1: /posts/:id/comments

A typical, “standard” blog backend with posts and comments would respond the following JSON API for a post request:

{
  data: {
    id: "1",
    type: "posts",
    attributes: { ... },
    relationships: {
      comments: {
        data: [
          { id: "1", type: "comments" },
          { id: "2", type: "comments" }
        ]
      }
    }
  }
}

This gives Ember Data a hint as to which comments to load. By default, it will hit /comments/1 and /comments/2 to satisfy the hasMany comments association. (Unless, of course, those comments were included in the post response payload.)

But what if we have a nested API structure?

  • GET /posts
  • GET /posts/:id
  • GET /posts/:id/comments

Such is the case of our simple Mirage JSON API backend.

As far as we can see, normalization is only required for posts so we'll use the PostSerializer. We need to:

  • remove data.relationships.comments.data (as seen in the response above, to prevent Ember Data from calling /comments/:comment_id)
  • add an appropriate link in data.relationships.comments.links that points to /posts/:id/comments

And that is exactly what the addLinks function does below. We'll call it from both findAll and findRecord!

// app/serializers/post.js

import ApplicationSerializer from './application';

export default class PostSerializer extends ApplicationSerializer {

  normalizeFindAllResponse(store, type, payload) {
    payload.data = payload.data.map(this.addLinks);
    return payload;
  }

  normalizeFindRecordResponse(store, type, payload) {
    payload.data = this.addLinks(payload.data);
    return payload;
  }

  addLinks(post) {
    post.type = 'post';
    delete post.relationships.comments.data;
    post.relationships.comments.links = {
      related: `/posts/${post.id}/comments`
    };
    return post;
  }

}

And that does the trick!

Hold on a second! Aren't adapters suppose to figure out location while serializers translate the payload?

That's exactly right. In this case, as location is construed from the payload, we use a serializer for the customization.

Example URL #2: /comments?postId=:id

Imagine our post API was slightly different. It doesn't return any information about comments, but it is known that a post's comments can be accessed at /comments?postId=:id.

The serializer would end up being:

// app/serializers/post.js

import ApplicationSerializer from './application';

export default class PostSerializer extends ApplicationSerializer {

  normalizeFindAllResponse(store, type, payload) {
    payload.data = payload.data.map(this.addLinks);
    return payload;
  }

  normalizeFindRecordResponse(store, type, payload) {
    payload.data = this.addLinks(payload.data);
    return payload;
  }

  addLinks(post) {
    post.relationships.comments.links = {
      related: `/comments?postId=${post.id}`
    };
    return post;
  }

}

These modifications can similarly be applied to RESTSerializer, ActiveModelSerializer and others.

For an in-depth understanding of adapters and serializers, see Fit Any Backend Into Ember with Custom Adapters & Serializers.

Enjoyed this article? Join Snacks!

Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)