In this chapter, we will learn how to write an adapter and serializer from scratch. If you didn’t find a technique in the previous chapter to get your API working with Ember Data, then this chapter and the next will give you more insight into how an adapter and a serializer work together and expose you to some of the core methods of each. The process of writing an adapter and serializer from scratch will help you find the right methods to override for your particular edge case. Furthermore, if you need to work with a wildly different API, then you will know how to write your own!
We will start by rebuilding a simplified version of the RESTAdapter and RESTSerializer for the standard CRUD operations.
Setup
I’ve created a simple contact application that performs all of the standard CRUD operations. The code for this application can be found in the chapter-5 folder of the source code for this book. We will use this application as a way of testing our custom adapters and serializer.
The API is set up using Ember CLI Mirage (www.ember-cli-mirage.com/). If you aren’t familiar with Mirage, it is a library that lets us simulate a backend.
Our Custom Adapter and Serializer
To start, let’s create our custom adapter called
my-rest that extends from the base
adapter class Adapter:
ember generate adapter my-rest
app/adapters/my-rest.js
import Adapter from '@ember-data/adapter';
export default class MyRESTAdapter extends Adapter {}
Next, create an
application adapter that extends from our custom adapter with an
api namespace:
ember generate adapter application
import MyRESTAdapter from './my-rest';
export default class ApplicationAdapter extends MyRESTAdapter {
namespace = 'api';
}
Let’s do the same for the serializer. We’ll create a custom serializer called
my-rest that extends from the base
serializer class Serializer:
ember generate serializer my-rest
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
export default class MyRESTSerializer extends Serializer {}
Lastly, create an
application serializer that extends from our custom serializer:
ember generate serializer application
app/serializers/application.js
import MyRESTSerializer from './my-rest';
export default class ApplicationSerializer extends MyRESTSerializer {}
Now that we have an application adapter extending from our custom adapter and an application serializer extending from our custom serializer, let's move on to the implementation and get store.findAll() working.
Finding All Records
Serve the test application, visit http://localhost:4200/contacts, and open up the browser console. You will notice that the page throws an error. This is because our application adapter and serializer no longer extend from the built-in RESTAdapter and RESTSerializer, respectively.
Let’s start by implementing the necessary methods to get the
/contacts page
working. If you look at the
contacts route, you will see the following:
app/routes/contacts.js
import Route from '@ember/routing/route';
export default class ContactsRoute extends Route {
model() {
return this.store.findAll('contact');
}
}
Let’s take a closer look at the error message.
Remember, the adapter is responsible for figuring out how to make requests to an API. As discussed in Chapter
2 – Talking to APIs with Adapters,
store.findAll() calls
adapter.findAll() behind the scenes. The
findAll() method on the adapter is used to find all records for a given type. Let’s go ahead and
implement that:
app/adapters/my-rest.js
import Adapter from '@ember-data/adapter';
import { pluralize } from 'ember-inflector';
import $ from 'jquery';
export default class MyRESTAdapter extends Adapter {
findAll(store, type, neverSet, snapshotRecordArray) {
let url = '/${this.namespace}/${pluralize(type.modelName)}';
return $.get(url);
}
}
The findAll() method on the adapter gets passed a few arguments, but the one we are particularly interested in is type, which contains the model class which has a property containing the model’s name. Here we are taking the model name and pluralizing it so that a GET request is made to /api/contacts. If we look at the console, we’re still getting an error:
The
normalizeResponse() method
must be implemented on the serializer, and it is responsible for normalizing a payload from the server into a JSON:API document. As mentioned before, Ember Data uses JSON:API internally, even if your API does not. Currently, our Mirage backend is returning a list of contacts like this:
{
"contacts": [
{
"id": "1",
"name": "Tom",
"phoneNumber": "(123) 456-7890"
}
]
}
Let’s go ahead and implement
normalizeResponse()
so that it takes this payload and returns a JSON:API document. If you aren’t familiar with the basic JSON:API structure, go back to Chapter
3 – API Response Formats and Serializers and read the section “The JSONAPISerializer”:
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
export default class MyRESTSerializer extends Serializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
return {
data: payload.contacts.map((resource) => {
return {
id: resource.id,
type: 'contact',
attributes: resource
};
})
};
}
}
The normalizeResponse() method
has several parameters, but the one we are interested in is payload. Now there are a lot of methods in the serializer, but if you have to remember one, normalizeResponse() is it. If you ever need to modify the payload before it gets into Ember Data, you can always override this method. There are other methods you can override to more efficiently manipulate the payload, but know that you can always use this one. Here we created the root data key that contains a list of contacts. Each contact is restructured to be JSON:API compliant, containing id, type, and attributes. Check the page again. Everything works!
We aren’t finished yet. If we tried to use this serializer with other models, it would fail because we have hard-coded
contact in
normalizeResponse().
Let’s modify this to make it more generic:
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
import { pluralize } from 'ember-inflector';
export default class MyRESTSerializer extends Serializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
let pluralizedModelName = pluralize(primaryModelClass.modelName);
return {
data: payload[pluralizedModelName].map((resource) => {
return {
id: resource.id,
type: primaryModelClass.modelName,
attributes: resource
};
})
};
}
}
Here we have utilized the primaryModelClass argument which is the model class for the records we are finding. We picked off the modelName and pluralized it to dynamically access the data from the root payload key. Loading the list of contacts still works! Let’s move on to findRecord().
Finding a Single Record
If we try clicking a contact, we’ll see that we get an error. In the
contacts.contact route, we are fetching a single contact by the
id dynamic segment:
app/routes/contacts/contact.js
import Route from '@ember/routing/route';
export default class ContactsContactRoute extends Route {
model(params) {
return this.store.findRecord('contact', params.id);
}
}
We need to update our adapter to get
store.findRecord() working. The adapter method that maps to
store.findRecord() is, you guessed it,
findRecord(). Let’s implement that:
app/adapters/my-rest.js
import Adapter from '@ember-data/adapter';
import { pluralize } from 'ember-inflector';
import $ from 'jquery';
export default class MyRESTAdapter extends Adapter {
// ...
findRecord(store, type, id, snapshot) {
let url = '/${this.namespace}/${pluralize(type.modelName)}/${id}';
return $.get(url);
}
}
This implementation is similar to the findAll() implementation that we did earlier, but this time the id of the record we are trying to find is tacked onto the end of the URL. Now, it still isn’t working yet. The console outputs the following error:
This error is now concerning the payload. Previously, we implemented
normalizeResponse() in the serializer and we used
Array.prototype.map() to turn the payload into a JSON:API-compliant document. However, the
GET /api/contacts/:id endpoint returns a single resource, not an array. The payload looks like this for
/api/contacts/1:
{
"contact": {
"id": "1",
"name": "Tom",
"phoneNumber": "(123) 456-7890"
}
}
To accommodate a single resource response, we can do this a few different ways. Remember how I said if you were to remember one serializer method, it should be
normalizeResponse()? Well, we can add some logic to this method to test whether an object or an array comes back:
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
import { pluralize } from 'ember-inflector';
export default class MyRESTSerializer extends Serializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
let { modelName } = primaryModelClass;
let pluralizedModelName = pluralize(modelName);
if (Array.isArray(payload[pluralizedModelName])) {
return {
data: payload[pluralizedModelName].map((resource) => {
return {
id: resource.id,
type: modelName,
attributes: resource
};
})
};
}
let resource = payload[modelName];
return {
data: {
id: resource.id,
type: modelName,
attributes: resource
}
};
}
}
With the preceding code, now we can click a single contact. This implementation is still not ideal though. We have some duplicated code for generating a JSON:API resource object. It turns out there is a method dedicated for normalizing a single resource object called
normalize(). The
normalize() method takes the type (the model class) and the resource object. Let’s clean this up a bit to do just that:
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
import { pluralize } from 'ember-inflector';
export default class MyRESTSerializer extends Serializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
let { modelName } = primaryModelClass;
let pluralizedModelName = pluralize(modelName);
if (Array.isArray(payload[pluralizedModelName])) {
return {
data: payload[pluralizedModelName].map((resource) => {
return this.normalize(primaryModelClass, resource);
})
};
}
return {
data: this.normalize(primaryModelClass, payload[modelName])
};
}
normalize(typeClass, hash) {
return {
id: hash.id,
type: typeClass.modelName,
attributes: hash
};
}
}
In the preceding code, the
normalize() method
is used to convert a single resource object that looks like this:
{
"id": 1,
"name": "Tom",
"phoneNumber": "(123) 456-7890"
}
into a JSON:API resource that looks like this:
{
"id": 1,
"type": "contact",
"attributes": {
"name": "Tom",
"phoneNumber": "(123) 456-7890"
}
}
Revisiting normalizeResponse()
Before we move on to creating records, let’s revisit
normalizeResponse()
on the serializer. If we were to take a look at the actual implementation of
normalizeResponse()1, we’d see something like this:
import Serializer from '@ember-data/serializer';
export default Serializer.extend({
// ...
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
switch (requestType) {
case 'findRecord':
return this.normalizeFindRecordResponse(...arguments);
case 'queryRecord':
return this.normalizeQueryRecordResponse(...arguments);
case 'findAll':
return this.normalizeFindAllResponse(...arguments);
case 'findBelongsTo':
return this.normalizeFindBelongsToResponse(...arguments);
case 'findHasMany':
return this.normalizeFindHasManyResponse(...arguments);
case 'findMany':
return this.normalizeFindManyResponse(...arguments);
case 'query':
return this.normalizeQueryResponse(...arguments);
case 'createRecord':
return this.normalizeCreateRecordResponse(...arguments);
case 'deleteRecord':
return this.normalizeDeleteRecordResponse(...arguments);
case 'updateRecord':
return this.normalizeUpdateRecordResponse(...arguments);
}
}
});
Ember Data implements normalizeResponse() by delegating to a normalization method for a specific request type. Whenever store.findRecord() is called, the normalizeFindRecordResponse() method on the serializer is called; whenever store.findAll() is called, the normalizeFindAllResponse() method on the serializer is called; and so on and so forth. If we need a custom normalization method for a specific requestType, we can override one of these normalization methods.
Furthermore, if we wanted to handle all responses that return a collection of resources one way and a single resource another way, there are dedicated methods for those too. These methods are
normalizeArrayResponse() and
normalizeSingleResponse(). In fact, each of the
requestType-specific normalization methods calls either
normalizeArrayResponse() or
normalizeSingleResponse() behind the scenes. Here is the execution flow (read from left to right):
{width="100%"} | normalizeFindAllResponse() | normalizeArrayResponse() | | | normalizeFindRecordResponse() | normalizeSingleResponse() | | | normalizeCreateRecordResponse() | normalizeSaveResponse() | normalizeSingleResponse() | | normalizeDeleteRecordResponse() | normalizeSaveResponse() | normalizeSingleResponse() | | normalizeUpdateRecordResponse() | normalizeSaveResponse() | normalizeSingleResponse() | | normalizeQueryResponse() | normalizeArrayResponse() | | | normalizeQueryRecordResponse() | normalizeSingleResponse() | | | normalizeFindBelongsToResponse() | normalizeSingleResponse() | | | normalizeFindHasManyResponse() | normalizeArrayResponse() | |
Hopefully, this provides more insight into how normalization works in serializers.
Now that we can fetch data with store.findAll() and store.findRecord(), let's move on to the implementation for creating records.
Creating Records
Let’s move on to creating records. Go ahead and visit http://localhost:4200/contacts/new. Fill out and submit the form for a new contact. You should see the following error:
To create a record, we need to use
store.createRecord(). This doesn’t save the record though. To save the record,
save() needs to be called on the record to persist those changes via a
POST /api/contacts request
, for example:
app/controllers/contacts/new.js
let contact = this.store.createRecord('contact', {
name: this.name,
phoneNumber: this.phoneNumber
});
The
adapter method that gets called from
model.save() is
createRecord(). Our API expects a JSON payload to contain the data under a root key that matches the model name, similar to when we fetch a single record:
{
"contact": {
"name": "David",
"phoneNumber": "(310) 123-4567"
}
}
Here is an implementation of
createRecord():
app/adapters/my-rest.js
import Adapter from '@ember-data/adapter';
import { pluralize } from 'ember-inflector';
import $ from 'jquery';
export default class MyRESTAdapter extends Adapter {
// ...
createRecord(store, type, snapshot) {
let data = {};
let serializer = store.serializerFor(type.modelName);
serializer.serializeIntoHash(data, type, snapshot);
return $.ajax({
type: 'POST',
url: '/${this.namespace}/${pluralize(type.modelName)}',
data: JSON.stringify(data)
});
}
}
Inside createRecord(), a POST request is made using the pluralized model name to create the endpoint, such as /api/contacts, so that it is reusable for other models. How do we get the data to send? The data is contained within the snapshot argument. The snapshot argument is an instance of Snapshot, and it represents a record at a given moment in time. We’ll discuss this more later. For now, just know that it is an object that contains the record we are saving.
Remember, the role of a serializer is to format data sent to and received from the server. Instead of formatting the snapshot data in the adapter, which we could do, we should do this in the serializer. To get access to our model’s serializer, we can call the serializerFor() method
on the store, giving it the model name. Next, serializers have a serializeIntoHash() method that can be called to format the request payload data. In this case, we are using serializeIntoHash() to build the data variable, which is modified by reference. We will implement this method in a moment. The type and snapshot arguments are also passed along. This data variable becomes our stringified JSON payload.
Lastly, we need to implement the
serializeIntoHash() method
on our serializer so that the data sent to the server matches the format expected by the backend:
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
import { pluralize } from 'ember-inflector';
export default class MyRESTSerializer extends Serializer {
// ...
serializeIntoHash(hash, typeClass, snapshot) {
let serializedData = {};
snapshot.eachAttribute((name) => {
serializedData[name] = snapshot.attr(name);
});
hash[typeClass.modelName] = serializedData;
}
}
Our implementation of the serializeIntoHash() method looks at the model data contained in the snapshot and generates the payload the API expected. Snapshots have an eachAttribute() method that can be used to iterate through all the attributes on the model. We can get access to a record’s attribute using snapshot.attr(). In the preceding code, we are iterating over all the attributes and setting them onto serializedData which will get sent to the server.
You might be wondering, “why did the Ember Data team create this extra snapshot object instead of using the record?” As I mentioned earlier, a snapshot is a class in Ember Data that represents a record at a given moment in time. When working with records, you can inspect asynchronous relationships, and if those relationships are not loaded, Ember Data will trigger a request to fetch that data. Unlike with regular records, a snapshot is an object that represents a record that can be inspected without causing side effects, like triggering requests. The snapshot has only a few properties and
methods on it that you’ll likely use, as shown in Table
5-1.
Snapshot Property/Method | Description |
---|
snapshot.id | Gets the ID of the record |
snapshot.attr(‘name’) | Gets an attribute of the record |
snapshot.hasMany(‘emails’) | Gets a hasMany relationship for the record. Returns another snapshot |
snapshot.belongsTo(‘company’) | Gets a belongsTo relationship for the record. Returns another snapshot |
snapshot.record | Gets the original record |
snapshot.eachAttribute(callback, binding) | Iterates through all model attributes and invokes the callback on each attribute |
Now if you were to look at the source code for the
RESTSerializer, it actually breaks up this functionality into two separate methods:
serializeIntoHash() and
serialize(). We can adjust our implementation to match what Ember Data is doing a little more closely:
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
import { pluralize } from 'ember-inflector';
export default class MyRESTSerializer extends Serializer {
// ...
serializeIntoHash(hash, typeClass, snapshot) {
hash[typeClass.modelName] = this.serialize(snapshot);
}
serialize(snapshot) {
let serializedData = {};
snapshot.eachAttribute((name) => {
serializedData[name] = snapshot.attr(name);
});
return serializedData;
}
}
The serialize() method
is responsible for grabbing the data out of the snapshot and formatting it as needed, and the serializeIntoHash() method is responsible for customizing the root payload key.
Great! We can now find all records, find a single record, and create records. Let’s continue on and handle updating records.
Updating a Record
Go ahead and click a contact. You will see a modal pop up that allows us to edit a contact.
Now that we’ve implemented createRecord(), updateRecord()
is fairly similar. The two differences are the URL and the request type. First, the URL includes the id of the record to update, such as /api/contacts/1 instead of /api/contacts. Second, the PUT HTTP method is used instead of POST. That’s really it.
If we try and submit the form, we’ll get the following error:
Let’s go ahead and do that:
app/adapters/my-rest.js
import Adapter from '@ember-data/adapter';
import { pluralize } from 'ember-inflector';
import $ from 'jquery';
export default class MyRESTAdapter extends Adapter {
updateRecord(store, type, snapshot) {
let data = {};
let serializer = store.serializerFor(type.modelName);
serializer.serializeIntoHash(data, type, snapshot);
return $.ajax({
type: 'PUT',
url: '/${this.namespace}/${pluralize(type.modelName)}/${snapshot.id}',
data: JSON.stringify(data)
});
}
}
We have to make one small adjustment to the serializer, specifically the
serialize() method. Currently when a record is serialized, a plain object is created with all of the model’s attributes, but the
id isn’t included if one is present. We’ll add a check in there so that when a snapshot has an
id, the
id will be included in the serialized payload as well:
app/serializers/my-rest.js
import Serializer from '@ember-data/serializer';
import { pluralize } from 'ember-inflector';
export default class MyRESTSerializer extends Serializer {
// ...
serializeIntoHash(hash, typeClass, snapshot) {
hash[typeClass.modelName] = this.serialize(snapshot);
}
serialize(snapshot) {
let serializedData = {};
if (snapshot.id) {
serializedData.id = snapshot.id;
}
snapshot.eachAttribute((name) => {
serializedData[name] = snapshot.attr(name);
});
return serializedData;
}
}
That’s it for updating records! Now that we can update records, let’s continue on so that we can delete records.
Deleting a Record
Go ahead and click “Delete” for one of the contacts in the table. You should see the following error:
To delete a contact, we need to make a DELETE request to
/contacts/:id and the API will return an empty response. To make this request, we can call
destroyRecord() on our model. Calling
destroyRecord()
on the model maps to
deleteRecord()
on the adapter. Let’s implement that:
app/adapters/my-rest.js
import Adapter from '@ember-data/adapter';
import { pluralize } from 'ember-inflector';
import $ from 'jquery';
export default class MyRESTAdapter extends Adapter {
// ...
deleteRecord(store, type, snapshot) {
return $.ajax({
type: 'DELETE',
url: '/${this.namespace}/${pluralize(type.modelName)}/${snapshot.id }'
});
}
}
In deleteRecord(), an AJAX request is made with the DELETE HTTP method to the same URL that we created in findRecord() and updateRecord(). Give it a shot, and you should see everything working as expected.
Congratulations! You’ve completed a custom REST adapter and serializer for the standard CRUD operations!
Summary
In this chapter, we looked at how to write our own adapter and serializer from scratch, starting from the base classes that Ember Data provides. There are other methods in the adapter and serializer that were not covered in this chapter. While we could continue going over every adapter and serializer method, what we did cover should give you the necessary insight into how these two core pieces of Ember Data work together. When you do you need more functionality, finding the right adapter or serializer method in the documentation should be a breeze. In the next chapter, we will continue with the same application and change our backend to Local Storage by simply swapping the adapter, since the adapter is responsible for figuring out how to get data and where to send it for storage.