This post is one in a series on converting a Backbone app to Ember. See also Ember-Data Notes.
Recently, our team has been trying to get Ember-Data to work with an API that does not conform to the json:api
standard. The experience has been mostly good, but we've really struggled with the relationships. We have a Customer
model that has many pastInvoices
and a single monthToDateInvoice
. The URL for past invoices is /billing/invoice?explicit_customer_id={{customerId}}
; for month-to-date, it's /billing?no_list=true&explicit_customer_id={{customerId}}
. The JSON that the API returns for a customer does not include any link to those URLs.
Our first attempt was to create an InvoiceAdapter
that understood how to fetch invoices from those URLs:
// app/billing/invoices/adapter.js:
import DS from "ember-data";
const MONTH_TO_DATE_URL = '/billing?no_list=true&explicit_customer_id=%@';
const PAST_URL = '/billing/invoice?explicit_customer_id=%@';
export const MONTH_TO_DATE_ID = 'monthToDate';
export default DS.RESTAdapter.extend({
buildURL: function(type, id) {
const customerId = this.get('session.customer.id');
return id === MONTH_TO_DATE_ID ?
MONTH_TO_DATE_URL.fmt(customerId) :
PAST_URL.fmt(customerId);
}
});
combined with fetches in the BillingRoute
:
// app/billing/route.js:
import Ember from "ember";
import { MONTH_TO_DATE_ID } from "tango/billing/invoice/adapter";
export default Ember.Route.extend({
model: function() {
return Ember.RSVP.hash({
pastInvoices: this.store.find('billing/invoice'),
monthToDate: this.store.find('billing/invoice', MONTH_TO_DATE_ID),
customer: this.get('session.customer')
});
}
});
This almost worked. The problem was that storel.find('billing/invoice')
does a GET /billing/invoice?explicit_customer_id={{customerId}}
, but then returns all invoices in the store, including the month-to-date one fetched on the next line. In order to prevent this, we forced store.find
into "query-mode" by changing the call to this.store.find('billing/invoice', { _query: true })
. The API doesn't care about &_query=true
. Now this works, but it's pretty hack-ish.
The other problem here is that the InvoiceAdapter
is tied to fetching invoices only for the current customer. That's fine for most of our users, but we have privileged roles (e.g. internal sales) that allow the user to see invoices across many accounts.
What I really wanted was
// app/customer/model.js:
import DS from "ember-data";
export default DS.Model.extend({
monthToDateInvoice: DS.belongsTo('billing/invoice', { async: true }),
pastInvoices: DS.hasMany('billing/invoice', { async: true })
});
That way, I could just ask the current Customer
-- or any other Customer
-- for its Invoice
s. The JSON our API doesn't return any information about how to get from a Customer
to its Invoice
s. Fortunately, Ember-Data's Serializer
gives us lots of hooks in which we can manipulate the data coming back from the API.
I started by moving the Invoice
URL logic from InvoiceAdapter
into CustomerSerializer
:
import ApplicationSerializer from "tango/serializers/application";
const MONTH_TO_DATE_URL = '/billing?no_list=true&explicit_customer_id=%@';
const PAST_INVOICES_URL = '/billing/invoice?explicit_customer_id=%@';
export default ApplicationSerializer.extend({
normalizePayload: function(payload) {
payload.links = {
monthToDateInvoice: MONTH_TO_DATE_URL.fmt(payload.id),
pastInvoices: PAST_INVOICES_URL.fmt(payload.id)
};
return this._super(payload);
}
});
But I found that normalizePayload
wasn't getting called.
David J. Hamilton helped me get to the bottom of this one. I'm populating the Customer
with store.pushPayload
(when the user logs in). Specifically, I'm calling
this.store.pushPayload({ customers: [ { some: 'customerData' } ] });
Invoking pushPayload
with a single argument will first call ApplicationSerializer#normalizePayload
, then parse out the various kinds of objects (here just Customer
) and pass those hashes to their respective per-model Serializer#normalize
functions. Serializer#normalize
calls normalizeAttributes
and normalizeRelationships
, but not normalizePayload
, which is assumed to have happened before normalize
.
That left me two options:
- move my normalization logic into
Billing/InvoiceSerializer#normalizeRelationships
- change my invocation to
store.pushPayload('customer', { customers: [ { some: 'customerData' } ] });
The second seems like very odd coupling to me. I don't like the idea that passing a first argument to pushPayload
chooses which Serializer#normalizePayload
gets called. I may have to use that path for something in the future, so I'm glad I know it (and even more glad I'm writing it down here so when I inevitably forget it, I have a reference). For now, though, normalizeRelationships
seems like the way to go.
Thus:
import ApplicationSerializer from "tango/serializers/application";
const MONTH_TO_DATE_URL = '/billing?no_list=true&explicit_customer_id=%@';
const PAST_INVOICES_URL = '/billing/invoice?explicit_customer_id=%@';
export default ApplicationSerializer.extend({
normalizeRelationships: function(type, hash) {
hash.links = {
monthToDateInvoice: MONTH_TO_DATE_URL.fmt(hash.id),
pastInvoices: PAST_INVOICES_URL.fmt(hash.id)
};
return this._super(type, hash);
}
});
And now my BillingRoute
can be significantly cleaned up:
// app/billing/route.js:
import Ember from "ember";
import { MONTH_TO_DATE_ID } from "tango/billing/invoice/adapter";
export default Ember.Route.extend({
model: function() {
return this.get('session.customer');
},
afterModel: function(customer) {
return Ember.RSVP.all([
customer.get('monthToDateInvoice'),
customer.get('pastInvoices')
]);
}
});
- Use Ember-Data's relationships.
- Where your API lacks link information, use per-model
Serializer
s to inject it to support those relationships. normalizePayload
happens outsidenormalize
and is called bypushPaylaod
normalizeAttributes
andnormalizeRelationships
happen insidenormalize
and are called on per-modelSerializer
s
@jamesarosen thanks for this; it solved my biggest hangup with ember data. I am trying to keep my application up to date and upgrading to ember data 1.13.8 has been challenging. Do you have any suggestions on how to implement the
normalizeRelationships
function using the new serializer api?