Nested URLs for Ember Data's RestAdapter proposal

I’d say it should be inferred of the context too. I have some resources that I have multiple way to access it. For instance, I could access all the messages of a user user /users/2/messages, but I could also access all messages using /messages (for admin purpose, for instance).

App.Message = DS.Model.extend({
	content: DS.attr('string')
});

App.Conversation = DS.Model.extend({
	messages: DS.hasMany('message', {allowNesting: true});
});

this.store.find('message'); // GET /messages
this.store.find('conversation', 1).get('messages'); // GET /conversations/1/messages

    // This would trigger two requests, so many it's better to allow a notation like that:
    this.store.find('conversation.messages', 1); // GET /conversations/1/messages

(If allowNesting is set to false - which should be the case by default -), then accessing the /messages would always hit the root URL).

It should be smart enough to make some assumption, so if we are currently in a route that have :conversation_id parameter, and that we have a nested MessageRoute, a call to “this.store.find()” should do it based on the context (so a conversation):

App.ConversationMessageRoute = Ember.Route.extend({
	model: function() {
		return this.store.find('message'); // Hit /conversations/:conversation_id/messages
	}
});

But this should be easily overridable, for instance using some kind of options to the “find” method that would force to access the resource using its base URL:

return this.store.find('message', {forceRoot: true}); // Will hit "/messages"

Now, something important is trying to avoid too deeply nested URL. This is an often done error in REST design to have very deeply URL. For instance, “/users/5/tweets” is a good idea, but “/users/5/tweets/6/retweets” is definitely not a good idea and should be rewritten “/tweets/6/retweets”. I’d suggest limiting the nesting to one level, and always “drop” parts of the URL to avoid a too nested structure. OR we allow to configure that per nested resource:

App.User = DS.Model.extend({
	tweets: DS.hasMany('tweets', {allowNesting: true, maxNestingDepth: 2})
});

App.Tweet = DS.Model.extend({
	retweets: DS.hasMany('tweets')
});

EDIT: another idea would be to introduce a findAssociation method:

this.store.findAssociation('messages', 'conversation', {post_id: 43}}; // post_id could be infer from routes parameters

How would you run into this scenario in real life?

When you want to create a record and you have the parent resource but not the new record, for instance, creating "/posts/1/comments".

You might have an endpoint for creating comments under /posts/1, but not at /comments.

Great initiative! Two things that come to mind: multiple end-points for the same record and non-CRUD actions. I’d be happy to hear your thoughts!

Multiple end-points for the same record

We’ve got use-cases similar to those @bakura mentions: records exist at multiple endpoints.

Filtering encoded in endpoint path

  • /archived
  • /users/2/todos
  • /pending

may all return Todo records (current user, archived; given user; and pending respectively).

This could be reorganized into /todos?filter=archived, /todos?user=2 etc. but I think this is common (the Github API has a few).

Search results

In search, say Elastic Search, the query may contain a json payload, but any updates/writes would go to another endpoint.

Write/read and replication

There may be a read-only replica with data [geographically] closer to the user. E.g. eu.example.com/todos may handle both read and writes, and us.example.com/todos may be read-only.

Possible solution

Perhaps having named endpoints in the adapter would work. With the default one being inferred from the record as it is now. Such that:

  • store.find('todo') => /todos
  • store.find('todo', { endpoint: 'user', userId: 2 }) => /users/2/todos

Naming is tentative, I’m not sure ‘endpoint’ is the ideal word here.

Non-CRUD actions

Don’t know if this falls under the scope of this proposal, but sometimes non-CRUD actions have dedicated endpoints. Here is an example from the Stripe API. We’ve got similar ones in ours.

Our current solution is an out-of-band request, using pushPayload on the response, to update the model state. This works, but being able to define these actions on the adapter would be neat.

Possible Solution

// in router
record.operation('refund');

// in adapter
var ChargeAdapter = RESTAdapter.extend({
  operations: {
    'refund' : { path: '/charges/:id/refund', method: 'put' }
  }
})

Naming is tentative, I’m not sure ‘operation’ is the ideal word here.

For example, the Strips API has a bunch of these (https://stripe.com/docs/api/curl#refund_charge).

Regarding Non-CRUD action, I like the EPF solution: http://codebrief.com/2013/07/10-things-you-can-do-with-epf/ (see point 8). I’d love if Ember-Data had support for that.

Non-CRUD actions is definitely something that should be supported in the future, but seems out of scope for this proposal.

bakura, afaik Ember.js is backend–agnostic and, given it’s a client, has no right whatsoever to define how URLs should look like. This decision belongs to the backend.

The opposite is true too: it’s definitely not to the backend to adapt to EmberJS needs, but rather the opposite by giving all the tools to easily override things like that.

@bakura, I have to agree with @kurko here. Saying “only one layer of nesting” is arbitrary—if we’re going to restrict what people do, we should just say they cannot have any nesting, which is in fact how I would build a new API to work with Ember. If you can dictate only two levels, you can dictate only one. But I don’t think we can get away with dictating either.

Sorry if I expressed myself wrongly. I agree with you, Ember-Data should allows any nesting, even if not very convenient to use. However, it should allows use cases where you don’t want to have infinite nestings. I personally don’t work to work with very very deep URL (because they are harder to optimize on back-end, and because they are simply not convenient).

Wow, I leave for the weekend, and now there’s all this! Thanks for the feedback everyone. Here are some responses, in no particular order:

At first I misread this, thinking that you were passing in a fragment of the final url. Now I understand that you are suggesting passing in the missing information encoded in a string with the id. This doesn’t make sense to me as 1/post/15 is not part of the id. It’s just metadata needed to build the url.

I ran into this situation when trying to build a url with one more level of nesting. I have the parent, but it doesn’t make sense to try and load up the grandparent (although it may be easier after SSOT). I don’t feel strongly about this particular interface though.

I actually still like the concept of having urlContextForRecord. In particular, being able to pass a simple object to build the url. To build 'posts/:post.id/comments/:id' without an actual comment record I need to build nested objects (as you pointed out with store.find('comment', 1, {post:{id: 15}})). It just makes sense to me that you’d want to be able to provide a single object that would contain all of the necessary information to build a url.

Another example of this is the loading from relationships case…

This would actually work well for me, but it is still difficult to build the url to fetch the relationship. buildURL still needs the context of the parent record (which is actually available in findHasMany). This, IMO, is a good reason for the context passed to be a simple object and not necessarily a record.

I will attempt to make some changes in my ApplicationAdapter to see if I can accommodate this in my app.

This is interesting, and reminds me of rails’ path generation helpers. I’m not sure if mirroring the routing API would be necessary to accomplish this (you just replace :(.*_)?id segments in the url specification); I like the concept.

I would like to avoid specifying this sort of thing in a model. I think this sort of configuration belongs in the adapter. Isn’t this why {sync: true} is going away?

Could this be accomplished with more than one adapter? Is it (or could it be) possible to specify an adapter when making a request?

I agree, not really under the scope of this proposal, but I would find this sort of thing useful. I particularly like the record.operation('refund'); interface.

I don’t consider this feature something most ember-data users will use. Anyone that has control over their API design should (IMO) not do any nesting. This feature is is proposed so that ember-data will be flexible enough to accommodate folks that are working with APIs that they cannot control.

Just for clarity, why is a nested API considered bad design? And assuming you flatten the API, what is the preferred way of passing in the associated object ids? Via query string?

I’m new to this debate (I’m just trying to help get it working in ember-data), but I think nested resources are much more difficult to handle generically from the client side. It is much simpler to say “when I create a comment, POST to /comments”. If I have an id of a comment, I should be able to just fetch it (/comments/:id), and not need to figure out what comment it belongs to before I can fetch it.

After dealing with this in my own app and trying to figure out how to make it less painful in ember-data, I’d have to say this has far more weight that at first it seems.

@mixonic explains in far more detail (with better arguments) here: Suggested REST API Practices (2013) :: madhatted.com

Also see:

You have an ember app that starts in the context of a parent relationship, fetching that isn’t necessary.

So, now I realize that findHasMany (which receives an instance of the parent record) is only called when there are links available (if there were, I wouldn’t be having this problem). In my case, findMany is called, and I was able to overwrite findMany (working with my custom buildURL).

After reading all the replies, IRC discussions and IM conversations it seems like there are several open questions. I’ll try to summarize:

  1. Whether adding the third argument to find is a good idea and if so, is the syntax find('comment', 1, {post:postObject}) and/or find('comment', 1, {post:15}) with the user property being set on the record good enough. The core problem that we are trying to solve is that the user has knowledge of a relationship but is unable to tell it to the store until the find call returns and we happen to need that relationship info in order to construct the url.

  2. How the microsyntax for the url sugar should work. I have proposed /:type/:post.id/:id to mirror object access as that is what it is actually doing, but various other proposals have been raised that would mirror the router api :type/:post_id/:id While I am not opposed to those, I have some magic/implementation doubts about how one is supposed to know that :post_id is referencing an id on a post object and not just a property called post_id

A use case to keep in mind is that there are other legitimate uses for passing metadata to find, such as thin/thick records, maybe pagination etc. I am of the strong opinion that a record should have access to all the data necessary to refetch itself, so for the thin/thick model case the current api would still work:

record = store.find('comment', 1, {thin: true})
//Adapter find hook gets passed a record where record.get('thin') === true and constructs an appropriate url
//later on when you need to know the state of the record
record.get('thin') -> true
4 Likes

@terzicigor, thanks for providing this summary.

I think the best solution to this is the urlContextForRecord hook you proposed.

@terzicigor What are your thoughts on multiple-endpoints and non-CRUD actions? (link to my post above). I understand if it’s outside the scope of this proposal, but would be interesting to hear regardless.

Did anything ever come of this discussion and the proposals given?

1 Like

The addition to the find api and changes to buildURL have already been implemented in beta.9. The short syntax for url is still an rfc RFC: Ember Data url templates by amiel · Pull Request #4 · emberjs/rfcs · GitHub

2 Likes

Thanks @terzicigor for the update. Very cool.