Let's say we have a GET /posts
API endpoint and it returns the following JSON:API-ish response:
{
"data": [
{
"id": "1",
"type": "posts",
"attributes": {
"title": "Post A",
"body": "...",
"tags": [
{
"id": "1",
"name": "JavaScript"
},
{
"id": "2",
"name": "Node.js"
}
]
}
},
{
"id": "2",
"type": "posts",
"attributes": {
"title": "Post B",
"body": "...",
"tags": [
{
"id": "1",
"name": "JavaScript"
},
{
"id": "3",
"name": "Ember.js"
}
]
}
}
]
}
In Ember Data, we'd probably want to model tags
as a hasMany
relationship to a post
model:
import Model, { attr, hasMany } from '@ember-data/model';
export default class PostModel extends Model {
@attr('string') title;
@hasMany('tag', { async: false }) tags;
});
Unfortunately, this API isn't responding with tags
as a JSON:API relationship. What can we do? If you're familiar with the EmbeddedRecordsMixin
, you might think to try using that:
import JSONAPISerializer from '@ember-data/serializer/json-api';
import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
export default class PostSerializer extends JSONAPISerializer.extend(
EmbeddedRecordsMixin
) {
attrs = {
tags: {
embedded: 'always',
},
};
}
However, this doesn't work, since the JSONAPISerializer
expects JSON:API relationships as opposed to nested attributes.
One approach to get this working would be to normalize the payload and turn tags
into a JSON:API relationship, which means transforming the above payload into the following:
{
"data": [
{
"id": "1",
"type": "posts",
"attributes": {
"title": "Post A",
"body": "..."
},
"relationships": {
"tags": {
"data": [
{
"id": "1",
"type": "tags"
},
{
"id": "2",
"type": "tags"
}
]
}
}
},
{
"id": "2",
"type": "posts",
"attributes": {
"title": "Post B",
"body": "..."
},
"relationships": {
"tags": {
"data": [
{
"id": "1",
"type": "tags"
},
{
"id": "3",
"type": "tags"
}
]
}
}
}
],
"included": [
{
"id": "1",
"type": "tags",
"attributes": {
"name": "JavaScript"
}
},
{
"id": "2",
"type": "tags",
"attributes": {
"name": "Node.js"
}
},
{
"id": "3",
"type": "tags",
"attributes": {
"name": "Ember.js"
}
}
]
}
We could do this by creating a custom post
serializer that extends JSONAPISerializer
and override the normalizeFindAllResponse
method (assuming store.findAll('post')
is being called):
ember g serializer post
import JSONAPISerializer from '@ember-data/serializer/json-api';
export default class PostSerializer extends JSONAPISerializer {
normalizeFindAllResponse(store, primaryModelClass, payload, id, requestType) {
payload.data.forEach(resource => {
resource.relationships = {
tags: {
data: resource.attributes.tags.map(tag => {
return {
id: tag.id,
type: 'tags',
};
}),
},
};
});
payload.included = payload.data
.map(resource => {
return resource.attributes.tags;
})
.flat()
.map(tag => {
let resource = {
id: tag.id,
type: 'tags',
};
delete tag.id;
resource.attributes = tag;
return resource;
});
return super.normalizeFindAllResponse(...arguments);
}
}
Check out a demo if you'd like to see this in action.
What do you think? Personally I found this solution to be a lot of code to have to write and maintain.
If we look at the GET /posts
response again, if we remove the keys data
, type
, and attributes
, it looks pretty similar to the expected payload structure of the JSONSerializer
. Although unconventional, why not try using the JSONSerializer
alongside the JSONAPIAdapter
instead of the JSONAPISerializer
? By doing so, we can then leverage the EmbeddedRecordsMixin
for tags
. Let's try it!
If you'd like to learn more about the different serializers, read my other blog post Which Ember Data Serializer Should I Use?.
import JSONSerializer from '@ember-data/serializer/json';
import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
export default class PostSerializer extends JSONSerializer.extend(
EmbeddedRecordsMixin
) {
attrs = {
tags: {
embedded: 'always',
},
};
normalizeFindAllResponse(store, primaryModelClass, payload, id, requestType) {
let newPayload = payload.data.map(({ id, attributes }) => {
return { id, ...attributes };
});
return super.normalizeFindAllResponse(
store,
primaryModelClass,
newPayload,
id,
requestType
);
}
}
I also need to create a serializer for the tag
model that extends from JSONSerializer
, since each tag in the payload follows the structure expected by that serializer:
ember g serializer tag
import JSONSerializer from '@ember-data/serializer/json';
export default class TagSerializer extends JSONSerializer {}
Check out a demo if you'd like to see this in action.
What do you think? To me, it is less code and much simpler.
Here is another way we could implement the post
serializer:
import JSONSerializer from '@ember-data/serializer/json';
import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest';
export default class PostSerializer extends JSONSerializer.extend(
EmbeddedRecordsMixin
) {
attrs = {
tags: {
embedded: 'always',
},
};
normalize(typeClass, { id, attributes }) {
return super.normalize(typeClass, { id, ...attributes });
}
normalizeFindAllResponse(store, primaryModelClass, payload, id, requestType) {
return super.normalizeFindAllResponse(
store,
primaryModelClass,
payload.data,
id,
requestType
);
}
}
Instead of mapping over the array in normalizeFindAllResponse
as we did in our previous implementation, we can let Ember Data do that and just override the normalize
hook which gets called for each resource. I think this approach might result in one less loop than our last implementation since we eliminated the map
call, but I could be wrong. If anyone knows, please drop a line in the comments!
Conclusion
Ideally, this API would use a JSON:API relationship for tags
. However, for one reason or another, I have run into this scenario enough times where relationships aren't used and the API won't be changed. Although unconventional, in these cases I found it useful to use the JSONSerializer
alongside the JSONAPIAdapter
instead of the JSONAPISerializer
.