by

Ember Data: URIs for singular resources

: updated with some errors fixed. I did a terrible job the first time around.

NOTE: I use the term "URI" here as I consider it the correct one for the context, despite the API using the term "URL". Maybe I'm just being pedantic, dunno…

For info on the difference between URL and URI, check out https://danielmiessler.com/study/url-uri.

Coming from Ruby on Rails and other similar frameworks, I am used to the concept of "singular resources": resources that we can request from the API without providing a specific id, because it is implied in the request. So for example, these resource URIs:

Ember Data doesn't support singular resources by default: you cannot use findRecord or similar to retrieve one of these out of the box. Fortunately, the provided adapters are easy to override to support these singular resources. Let's see how to do this.

First, a refresh: Ember Data ships by default with two adapters, JSONAPIAdapter (the default) and RESTAdapter, which implement two common protocols to communicate with JSON APIs. If your API communicates differently, both adapters provide hooks for you to override for your own particular case.

If you are going to define your own adapters, you should start by defining an ApplicationAdapter. This will be a common adapter for all your models that you can then override for each specific case. It can just be empty. This is just to provide a common ground for adapters specific to each model. Something like this:

1/// app/adapters/application.js
2import JSONAPIAdapter from 'ember-data/adapters/json-api';
3
4export default JSONAPIAdapter.extend({
5});

This example is actually equivalent to the Ember Data default as it is simply an extension of JSONAPIAdapter. I could have dispensed with the extend call since it's empty, but I like to have it, as I know I'll be adding my custom code. Also, I could have used RESTAdapter instead, if appropriate for my specific API.

Now, let's say that we have an Ember Data model User, and we want to fetch a singular resource of this type from the API. To do this, we'll create a custom adapter so that it retrieves this resource when we do the following:

1const singularUser = this.store.queryRecord('user', {singular: true});

The first thing to note is that I am using queryRecord instead of findRecord. You should use findRecord when you know the id of the resource you are requesting. This allows the store to retrieve this record from cache if appropriate. In this case, we do not know the id, and Ember Data's interface specifies that queryRecord is the correct API to use.

Also, we provide the {singular: true} argument. With this we signal to our custom adapter that we want the singular resource. Note that this is not something that Ember Data understands by default, but instead it is a convention between our custom adapter and its client code. I have seen codebases using other words instead of singular, such as me or current. Which one to use is up to you.

Now that we have clarified how we'll use our custom adapter, we'll build it. We can start by extending our ApplicationAdapter by overriding the buildURL hook. This is a method that the adapters use and is meant to be customised if we need to. It has several parameters, of which the following are useful to us:

With these three parameters, we can tell when our code is requesting a 'queryRecord' for a "singular" resource, for any model type. Here's an implementation:

 1/// app/adapters/application.js
 2import JSONAPIAdapter from 'ember-data/adapters/json-api';
 3
 4export default JSONAPIAdapter.extend({
 5  buildURL(modelName, id, snapshot, requestType, query) {
 6    if (requestType === 'queryRecord' && query && query.singular) {
 7      return '/' + modelName;
 8    } else {
 9      return this._super(...arguments);
10    }
11  }
12});

So when the client code is requesting a singular resource, return the URI for it. Otherwise, just call this._super and do as the adapter would normally do. Normally, this will return three types of URIs, depending on the request. For user resources, they will be these:

If you try the code, it should work now. There will be an interesting hitch though: your requests to the singular resource will also include the query parameter ?singular=true. This is the default behaviour of queryRecord: sending the keys and values of the second argument as query params. In most cases, this is probably ok with your API as it will just ignore it. However, if you would rather not see that unseemly addition to the request, you can just do some more adapter overriding.

To do this, I can think of a couple of methods that could be overriden. My preference is to go for sortQueryParams, which is normally used to change the order of the query parameters (which is rarely necessary). We'd be cheating a bit here, because this method is intended to change the order and we are instead modifying the query altogether, by removing a key/value pair. I think it's still acceptable.

Having said that, this is a possible implementation:

 1/// app/adapters/application.js
 2import JSONAPIAdapter from 'ember-data/adapters/json-api';
 3
 4export default JSONAPIAdapter.extend({
 5  // ...
 6
 7  sortQueryParams(query) {
 8    let newQuery = Object.assign({}, query);
 9    delete newQuery.singular;
10    return newQuery;
11  },
12});

I'm using Object.assign to make a copy of the original simply because I favour the "immutable" style of programming. I could just delete the key from the input argument and return it again. Do it the way it you like best.

Anyway, that's it. The adapters and serializers that Ember Data bundles by default have an extensive number of hooks that you can take advantage of, and their documentation has improved a lot over time. I recommend that you have a look at it.

Still, I actually learned all this by reading Ember Data's source code, before these hooks were so well documented. It's very easy to read, and a few console.log calls in the right places will show you what's actually going on when you interact with the library. Go try yourself, it's a better way to learn. If you find something that is not well documented, that's your chance to contribute ;-)