© The Author(s), under exclusive license to APress Media, LLC , part of Springer Nature 2021
D. TangPro Ember Datahttps://doi.org/10.1007/978-1-4842-6561-1_5

5. Writing an Adapter and Serializer from Scratch

David Tang1  
(1)
Playa Vista, CA, USA
 

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.

Error

You tried to load all records, but your adapter does not implement findAll.

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:

Error

serializer.normalizeResponse is not a function

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:

Error

Cannot read property ‘map’ of undefined

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:

Error

You tried to update a record but your adapter (for contact) does not implement createRecord

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
});
contact.save();
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.

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:

Error

You tried to update a record but your adapter (for contact) does not implement updateRecord

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:

Error

You tried to update a record but your adapter (for contact) does not implement deleteRecord

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.222.107.253