Skip to content

Instantly share code, notes, and snippets.

@jamesarosen
Last active October 9, 2016 17:55
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save jamesarosen/79337a515f557d7e9cf6 to your computer and use it in GitHub Desktop.
Ember-Data Relationships and Legacy APIs

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.

First Attempt: InvoiceAdapter

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.

Second Attempt: Relationships

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 Invoices. The JSON our API doesn't return any information about how to get from a Customer to its Invoices. 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:

  1. move my normalization logic into Billing/InvoiceSerializer#normalizeRelationships
  2. 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')
    ]);
  }
});

Summary

  • Use Ember-Data's relationships.
  • Where your API lacks link information, use per-model Serializers to inject it to support those relationships.
  • normalizePayload happens outside normalize and is called by pushPaylaod
  • normalizeAttributes and normalizeRelationships happen inside normalize and are called on per-model Serializers
@arenoir
Copy link

arenoir commented Sep 9, 2015

@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?

@arenoir
Copy link

arenoir commented Sep 9, 2015

Using the new serializer api introduced in ember-data 1.13 normalizeRelationships can be linked in the normalize function.

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({
  normalize: function(type, hash, prop) {
    hash.links = {
      monthToDateInvoice: MONTH_TO_DATE_URL.fmt(hash.id),
      pastInvoices: PAST_INVOICES_URL.fmt(hash.id)
    };
    return this._super(type, hash, prop);
  }
});

@jamesarosen
Copy link
Author

@arenoir thanks for that tip! We're in the middle of the upgrade to 1.13 ourselves and I was having a beast of a time figuring out the relationships.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment