Nested URLs for Ember Data's RestAdapter proposal


#1

Ember-Data REST Adapter Nested Urls

Currently Ember Data’s REST adapter sucks for highly custom urls. Moreover, the store api as it is not flexible enough to support complex and nested urls. This gist proposes an api for solving this.

Draft by: Amiel and Igor Terzic

A similar api was already proposed by Sidonath -> https://github.com/emberjs/data/pull/1078

Background & Rationale

There has been much discussion about nested urls in ember data. And quite a few attempts at supporting it.

As it is now, buildURL only receives a type and an id (in the cases where it is provided). A solution, proposed by @rjackson, is to provide the record to buildURL as well. However, this is only possible when creating, updating, or deleteing a record, and not possible when finding records (store.find('model'), store.find('model', id)).

High level api

The RESTAdapter would accept a url template.

var CommentAdapter = RESTAdapter.extend({
  url: 'posts/:post.id/comments/:id'
})

This would then work automagically for sending the record to the correct url. It is easy to see that this can be made to work for record.save() and record.delete() because createRecord, updateRecord, and deleteRecord operate on records, and we can just match the properties from the url slug to those on the record. The variables declared in the slug would be looked up on the record itself.

Find() is a bit more tricky, becuase at the time it’s called there is no record to be accessed. Thus the find api would be

store.find(type, id, context)

For example

store.find('comment', 1, {postId:15})

While this seems a bit unwieldy I would argue it’s actually a simple generalization of how find works. Right now find accepts type and id in order to know how to access the record’s url. Though type and id are somewhat special there is no good reason to limit the api to these two, especially as there are servers in the wild that need more context in order to return records.

You could imagine a more pure implementation of store.find and the RESTAdapter that looked like

//Just a thought exercise, not an actual proposal!
var RESTAdapter = Adapter.extend({
  url: "/:type/:id"
})
store.find({id:1, type:'user'})

Building block/lower level apis

Hopefully most users wouldn’t need to use/extend these

buildUrl: (context, requestType) -> string

BuildUrl would do the actually work of taking a context object, mapping it’s variables according to the url template and returning a url string.

We considered having a urlContextForRecord which would allow you to have a context object passed to buildUrl that had additional data compared to the data available on the record. For example some APIs might need to prepend currentUserId in the route, but they only keep the user id in the global session object. I believe that this is an antipattern and should intentionally be made hard to do. A record should have all the properties needed to construct a url available on itself. As a workaround, it seems easy to use a record/adapter hook or injection to set currentUserId or all models if you needed to.

For the same reason, the properties passed to find as the context, would get set on the record.

var comment = store.find('comment', 1, {postId: 15})
comment.get('postId') -> 15

This would also allow out of the box support for pathological APIs which have relationships declared in the URL and not in the JSON.

##Open questions There is tension between making the context passed in to find super easy to use vs making the relationships work nicely.

With a basic post, comments setup and an adapter that looks like:

var CommentAdapter = RESTAdapter.extend({
  url: '/posts/:post.id/comments/:id'
})

It would be nice if you could do,

var comment = store.find('comment', 1, {post_id: 15})

However, as it is, you would need to do:

var comment = store.find('comment', 1, {post:{id: 15}})

And even worse, for some cases it might require actually creating the post first in order to pass it in as the context

var post = store.find('post', 15)
var comment = store.find('comment', 15, {post:post})

##Alternative approaches considered

Compared to having the properties instantiated on the model directly, we also thought about making them live under a urlParams key so they couldn’t be actual relationships/attributes:

var comment = store.find('comment', 1, {post_id: 15})
comment.get('urlParams.post_id') -> 15
comment.get('post') -> null

This seems like a worse api overall, and harder to implement as you need to make sure your record doesn’t get into an inconsitent state, where urlParams don’t match the actual attribtues on the model.


Should we keep using Ember Data?
Current way to handle nested resources?
Loading multiple models in a single route
I was being silly to fight Ember-Data on nested URLs, I think
Creating radio buttons or something similar
Ember-Graph - An Ember-Data alternative
#2

does saving/deleting work by retrieving the values from the model itself?

something like:

comment.save(); 
// => PUT "/posts/" + comment.get('post.id') + "/comments/" + comment.get('id') 

#3

Yes it does. We were thinking about a urlContextForRecord hook that would take a record, and extract the needed properties, and by default would just proxy the entire record. Something like:

//default 
urlContextForRecord: function(record){
  return record;
}
//A custom one for a post, ends up with no real use case
//so the hook was scrapped
urlContextForRecord: function(record){
  return {post_id: record.get('post.id')}
}

We decided against this also because it seemed like it would give a place to put in url variables that don’t exist on the record, and I would like to strongly discourage that. A common example I heard is having currentUserId on a global session object, but if your models need, you really should inject it there.


#4

I’d disagree that there’s no good reason. The reason is that the tuple of [type, id] is the minimum needed to uniquely identify a record. Adding additional named params complicates disambiguating one record from another.


#5

Since you need to uniquely identify records anyway (to not break the identity map), why do you need this API at all? Since you are required to pass IDs in as the optional context, why do (as proposed):

store.find('comment', 1, { postId: 15 });

When you can do the following without making any changes:

store.find('comment', '1/post/15');

The adapter could interpret the ID however it’d like; IDs are, after all, adapter-specific. In this case, it could extract the additional information out by splitting on /.


#6

@tomdale although people will make very interesting composite keys, I hope we can continue to just support id + type


#7

So while that’s true in lots of cases I would disagree that the [type, id] is always the minimum needed to uniquely identify a record, and now that I am thinking about it, feel like some ED use cases are hampered by relying on that fact. Some examples:

  1. Singleton records are identified just by their type, and because of ED’s reliance on type/id composition needs hacks to work
  2. APIs where nested ids are not unique, where ‘posts/1/comments/15’, is a different record than ‘posts/2/comments/15’
  3. Polymorphic records

#8

Also, what’s the case where you have the ID of the parent but not the record itself?

var comment = store.find('comment', 1, {postId: 15});

How would you run into this scenario in real life?


#9

Having ‘1/post/15’ in the app layer seems like a non starter to me.


#10

Why? Do you agree that IDs are one of the rare cases where we can’t help but leak adapter-specific details into the app? (Queries are another example.)


#11

Think about someone refactoring their api from crappy nested routes to nice shallow routes.

My proposal would require just deleting the custom ‘url’ property in the adapters, while treating ‘1/post/15’ as an id would require someone to refactor the app code as well.


#12

Several examples:

  1. You have a user session singleton object which has an ID, but because it’s a singleton you didn’t make a ED model. You still need to use it’s id though

  2. Yesterday I was looking at a case where someone navigates to a route in an app that looks like ‘posts/1/comments/25’, but only wants to fetch the comment as the posts are very slow and not needed, though their id is needed to access the comment.


#13

I’m not sure developers changing the hierarchy of their models is a refactoring hazard we can code around. Are you suggesting that people would do that change, but still leave the calls to find('comment', 1, { postId: 15 }) in their app (with the concomitant setting of the postId property)?


#14

Thinking of how people would use this, they would have to construct the strings themselves before calling find. So having to always do things like

var id = '/' + this.get('post.id') + '/comments' + this.get('id')
store.find('comment', id)

seems much less convention driven and refactor resistant compared to

store.find('comment', id, {post: this.get('post')})

The second one describes relationships between your records, while the first one is hacking around with strings.

No downside to setting it if you already have the data. Also, once SSOT lands, in lots of cases you might not even have to pass in anything, as long as the other side is loaded, and your adapter could use the urlSlug to access the properties.


#15

I’m not too concerned with supporting this case. The workaround is just to create an ED model, or create the composite key yourself. (We should definitely make singleton support much better—no disagreement there.)

This is solved by the composite key approach as well. Just put two dynamic segments in your route’s path and concatenate them into a single key.


#16

I concede those two points, TBH the uses cases didn’t seem to strong to me either but I mentioned them cause I ran into them.

I still don’t like the composite key approach. Composite keys require you to mess with serialization of ids and don’t describe the nature of relationships. What happens when eventually you want to serialize one model to two different endpoints through two different adapters. Keeping the id canonical and then using the adapters url handling seems like a much cleaner approach that doing string interpolation and creating composite keys.

Also, what happens when you change the relationship? How do you keep the composite id working?

var comment = store.find('comment', '/post/1/comments/10')
var otherPost = store.find('post', 15)
comment.set('post', otherPost)

//Now you get into a state where
comment.get('id') -> '/post/1/comments/10'
comment.get('post.id')  -> 15

#17

Why not lookup nested resources through their parent? This would allow us to reuse the existing API, it would just be relative to a resource instead of the store i.e.

var post = store.find('post', 1);
var comment = post.find('comment', 1)
var comments = post.find('comment', { user_id: author.id })

Multiple nested resources follows easily…

var tenant = store.find('tenant', 1);
var post = tenant.find('post', 1);
var comment = post.find('comment', 1); 

This also provides a nice way to do pagination against what would otherwise be all or nothing relationships i.e.

var comments = post.find('comment', { page: 1 });

instead of

var comments = post.get('comments');

#18

I think this is a really elegant solution; unfortunately, it doesn’t handle the use case that @terzicigor raised (a valid one, IMO):


#19

Another option would be to mirror the routing API (which after all is also looking up resources), something like:

Adapter = RESTAdapter.extend(function() {
  this.resources("posts", function(){
      this.resources("comments");
  });
});

store.find('comment', post.id, 1)

I realise there’s a few issues here around handling plurality i.e. in the router we define plural and singular routes separately which would be excessive for a RESTAdapter.


#20

Find single model with id AND additional parameters is certainly needed. There are apps that work with 3rd party API-s that are out of developer control and often there is need to use some additional parameters. In addition to nested URL-s I have use cases where I need to define if API returns full record (lot of data) or only part of record (only main fields) and I have to use both queries in my app so it can’t be defined in adapter. I also have reports that have ID-s, but they also have some filters (get sales total report (ID=X) for day Y for client Z etc). Current workaround is doing findQuery and getting first record from array (as there is always only one record), but this is ugly hack. Embedding parameters to ID seems just another dirty hack. I once wrote down some related ideas as an github issue.