The Kinesis Tech Blog Data down, actions up

A migration path to JSON API with Ember Data from AMS

We've been using Ember.js for about a year now, and as is typical with Rails shops we began our journey with ActiveModelSerializers to drive our API. As our Ember application grew, we quickly discovered problems with the way AMS approaches serialization.

Some of the issues we ran into with AMS are:

  • Inability to handle requests for specific fields from the client. Eg. only return attributes a and b instead of a, b and c.
  • Sideloading models is per serializer and when there is a large number of related models, you often end up overfetching data

For example, given the following serializers...

class PostSerializer < ActiveModel::Serializer
  has_many :comments, embed_in_root: true
end

class CommentSerializer < ActiveModel::Serializer
end

...we would not be able to only get the posts without always returning the comment data. This quickly becomes untenable with a large number of related models. We had a number of ways to work around these problems (e.g. action specific serializers) but none of them were satisfactory.

With the release of Ember 2.0 and the transition to JSON API as the default adapter, we were keen to take advantage of the features it provides. However, we quickly discovered that our app was too large and too complex to do a full transition in a timeframe that fit our business requirements. We basically wanted to change as many small, isolated bits as we could, but still leave some of the meatier bits alone until we have more time to tackle them.

This wasn't a big deal for some parts of the site. For many of the Rails models it was as simple as deleting the serializer, and replacing it with a Resource class from the jsonapi-resources gem. But for one of our god models (asset) that has whole bunch of related records, we ended up having to maintain two Ember stores - AMS and JSON API - simultaneously, and handle the requests in the Rails controller differently depending on which store was requesting it. And since we couldn't find anyone else who'd tackled the same problem, we had to go it alone.

So, how did we do it?

Ember

First, we realised that we'd need a new store, as well as a new JSON API adapter and serializer for our asset model. The adapter and serializer would need to be the JSON API ones instead of the AMS ones:

// app/adapters/jsonapi-asset.js
import Ember from 'ember';
import DS from 'ember-data';

export default DS.JSONAPIAdapter;
// app/serializers/jsonapi-asset.js
import Ember from 'ember';
import DS from 'ember-data';

export default DS.JSONAPISerializer;

The interesting part is the new store. It would still need basic store functionality, but would need to call out to our new adapter and serializer. This is where the magic happens:

// app/services/jsonapi-store.js
import Store from 'integrated/services/store'; // the regular DS.Store

export default Store.extend({
  lookupAdapter(name) {
    if (name === 'asset') {
      return this._super(`jsonapi-${name}`);
    }
    return this._super(name);
  },

  lookupSerializer(name, fallbacks) {
    if (name === 'asset') {
      return this._super(`jsonapi-${name}`, fallbacks);
    }
    return this._super(...arguments);
  }
});

With these overridden methods, when we do a store.query('asset'), the store calls out to our above-mentioned jsonapi-asset adapter and serializer, but querying any other model falls through to the old AMS ones.

Great! :sparkles: So we're now sending off JSON API requests, but we're not going to be able to use this store until we inject it where it's needed. For that, we'll use a new initializer:

// app/initializers/jsonapi-store.js
export default {
  name: 'jsonapi-store',
  after: 'store',

  initialize(registry, application) {
    // any paths where you want 'store' to refer to the new jsonapi-store
    const paths = [
      'route:posts',
      'controller:posts'
    ];

    // inject jsonapi-store as 'store'
    paths.forEach((path) => {
      application.inject(path, 'store', 'service:jsonapi-store');
    });


    // in this case, we're injecting jsonapi-store with a different name
    application.inject('controller:assets', 'jsonapiStore', 'service:jsonapi-store');
  }
};

Now, store means the jsonapi-store in the posts route and controller, but in controller:assets, we have both store and jsonapiStore. Awesome!

Rails

On the Rails side, we need an asset resource as per the gem instructions, but we also need the controller to be able to handle both AMS style and JSONAPI style requests. Usually with jsonapi-resources, one can get away with just inheriting from the JSONAPI::ResourceController, but in our case we're going to need to mix in the functionality while still keeping the old methods around for the AMS style requests. Thankfully we can just include JSONAPI::ActsAsResourceController for that.

We're also going to need a callback to check which kind of request we're receiving, and route to the correct handler.

Lastly, we need to skip some of the gem callbacks that will prevent us from being able to use both kinds of requests.

# app/controllers/assets_controller.rb

class AssetsController < BaseController
  include JSONAPI::ActsAsResourceController # mix in the functionality

  before_action :route_to_jsonapi, if: :use_jsonapi?
  skip_before_action :setup_request # we'll perform this manually if it's JSONAPI
  skip_before_action :ensure_correct_media_type

  def use_jsonapi?
    # Ember sends different headers when using JSONAPI than with AMS,
    # so we can use this fact to determine which kind of request we're receiving
    request.content_type == JSONAPI::MEDIA_TYPE || request.headers["ACCEPT"] == JSONAPI::MEDIA_TYPE
  end

  def route_to_jsonapi
    setup_request # do it manually and...
    process_request_operations # let the gem take over control for JSONAPI
    false
  end

  # rest of the methods (index, show etc.) here
end

Also, don't forget to add the routes:

  jsonapi_resources :assets do
    jsonapi_relationships
  end

Now any requests that come in with JSONAPI will be handled by the gem and go through the corresponding JSONAPI::Resource. AMS ones can still hit the regular index, show, or even custom methods.

Relationships in Rails

Importantly, if you want to be able to use the really handy sideloading feature of JSONAPI, you'll need to make sure you create JSONAPI::Resources for your related records that includes the relationship:

class CommentResource < JSONAPI::Resource
  attributes :body

  has_one :post # <-- like this.
end

Then, as long as you have the jsonapi_relationships in the route, you can use include to sideload records:

this.jsonapiStore.query('comment', {
  include: 'post'
}).then((result) => {
  // ...
});

That should be all you need to maintain two simultaneous stores in Ember and Rails. Hopefully we haven't forgotten anything. If we have, feel free to ask a question or leave a comment.