How to deal with backend nested routes


#1

What is the best way to go on Ember side to deal with nested routes defined on backend side (Rails API) ? In a Rails app there are the nested routes defined as follows:

#routes.rb

resources :languages, only: :index
resources :shops do
  resources :shop_languages, path: '/languages'
end

on the Ember side the routes are defined as follows:

# router.js

Router.map(function() {
...
this.route('languages', { path: 'shops/:shopId/languages'});
});

How can I call create/delete actions for a shop language in this case ?

I have the action defined on save button in Ember template:

# templates/languages.hbs
<button type="button" class="btn btn-success btn-sm" onclick={{action "saveLanguage" aLanguage}}>

and the action defined in the controller:

# controllers/languages.js
export default Controller.extend({
...
 actions: {
  saveLanguage(aLanguage) {
    var shopLanguage = this.store.createRecord('shop-language', {
         language: aLanguage,
         shop: this.get('currentShop.shop'),
         modifiedBy: this.get('currentUser.user').get('username')
    });

   shopLanguage.save().then(function() {
          this.get('flashMessages').info('New language added with success');
      });
  }
}

In this case I have an error:

Started POST "/shop-languages" for 127.0.0.1 at 2018-04-03 11:58:08 +0200
  
ActionController::RoutingError (No route matches [POST] "/shop-languages"):

because Ember tried to POST at shop-languages route instead of shop/:shop_id/languages.

Here the list of routes in Rails:

shop_shop_languages GET    /shops/:shop_id/languages(.:format)     shop_languages#index
                    POST   /shops/:shop_id/languages(.:format)     shop_languages#create
 shop_shop_language GET    /shops/:shop_id/languages/:id(.:format) shop_languages#show
                    PATCH  /shops/:shop_id/languages/:id(.:format) shop_languages#update
                    PUT    /shops/:shop_id/languages/:id(.:format) shop_languages#update
                    DELETE /shops/:shop_id/languages/:id(.:format) shop_languages#destroy

Any idea ? Should I create an adapter to be able to create/delete a language for a shop ? By the way I already have one defined:

#adapters/shop-language.js
export default ApplicationAdapter.extend({
  urlForQuery (query/*, modelName*/) {
    return `/shops/${query.shopId}/languages`;
  }
});

Or should I use nested routes on the Ember side. What is better ? Thank you.


#2

This is personally why I wish _buildUrl in build_url_mixin passed in the snapshot too and was public.

Anyhoo, I recommend a similar pattern as in build_url_mixin:

  urlForQuery(query), {
    return this._buildShopUrl(query.shopId);
  },

  urlForCreateRecord(modelName, shapshot) {
    return this._buildShopUrl(snapshot.shopId);
  },

  urlForDeleteRecord(id, modelName, snapshot) {
    return this._buildShopUrl(snapshot.id, id);
  },

  _buildShopUrl(shopId, id) {
    if (id) {
      return `shops/${shopId}/languages/${id}`;
    }
    return `shops/${shopId}/languages`;
  }

#3

@Gaurav0 Thanks a lot for your reply, I didn’t hear about that mixin. Why not just include this mixin as described in the API ?Could you tell what is modelName attribute in urlForDeleteRecord method for ? Another question is why are you calling _buildShopUrl with just ONE parameter (query.shopId) and the method itself accepts 2 parameters - shopId and id? Thank you.


#4

The mixin mentioned is already included in your adapter. We are extending the methods from the mixin in the adapter class to change the implementation of the urlFor* methods.

As to the two arguments, sometimes the url has an id and sometimes it doesn’t.


#5

@belgoros

I would recommend using a top level POST to create new languages and send the property as an attribute/relation just like anything else.

I generally find that doing this opens up later flexibility and makes the API a bit more simple to reason about where nested routes like /shops/:id/languages is read only.

This also lines up with how jsonapi.org recommends formatting your URLs.


#6

@rtablada Could you please precise or give an example of how to do that, - I’m coming from Java/Rails world and actually most of Ember stuff is neither clear nor usual for me. And unfortunately, this kind of situation is not (yet?) covered by rare Ember books and tutorials. Thank you.


#7

@Gaurav0 I have an error when applying the solution you suggested.

  1. First, I fixed a typo in urlForCreateRecord(modelName, shapshot)- > shapshot changed for shapshot
  2. I already have a route defined as follows in router.js, may be this is the problem:
this.route('languages', { path: 'shops/:shopId/languages'});

And the corresponding link in applicationhbs template is defined as follows:

{{#link-to 'languages' currentShop.shop.id class="nav-link"}}
  1. When I click on a button to save a new shop language:
# templates/languages.hbs

<button type="button" class="btn btn-success btn-sm" onclick={{action "saveLanguage" aLanguage}}>
  <span class="oi oi-plus"></span>
   {{t 'buttons.add'}}
 </button>

save action:

# controllers/languages.js
actions: {
  saveLanguage(aLanguage) {
var shopLanguage = this.store.createRecord('shop-language', {
         language: aLanguage,
         shop: this.get('currentShop.shop'),
         modifiedBy: this.get('currentUser.user').get('username')
       });

       shopLanguage.save().then(function() {
          this.get('flashMessages').info('New language added with success');
      });
...

the generated URL is not correct:

POST http://localhost:4200/shops/613/shops/undefined/languages 404 (Not Found)

What’s wrong with that ?


#8

I made some modification to clarify the behaviour (at leats as it seems for me).

  1. I created shop_languages resource by moving what was defined in languages related resources:
  • route handler shop-languages.js:
export default Route.extend(AuthenticatedRouteMixin, {
  currentShop: service('current-shop'),

  model() {
    return RSVP.hash({
          shopLanguages: this.store.query('shop-language', { shop_id: this.get('currentShop.shop').get('id')}),
          languages: this.store.findAll('language')
        });
  }
});
  • controller 'shop-languages.js`:
  • template 'shop-languages.hbs`.

I also defined the new shop-languages route in router.js:

Router.map(function() {
  this.route('auth-error');
  this.route('callback');
  this.route('dashboard');
  this.route('information');
  this.route('address');
  this.route('phone-fax');
  this.route('social');
  this.route('links');
  this.route('description');
  //this.route('languages'/*, { path: 'shops/:shop_id/languages'}*/);
  this.route('shop-languages');
});

and changed the link in application.hbs template to pint to the new route without passing a query parameter:

{{#link-to 'shop-languages' class="nav-link"}}

The shop_id value is got in model hook in the shop-languages route handler:

model() {
    return RSVP.hash({
          shopLanguages: this.store.query('shop-language', { shop_id: this.get('currentShop.shop').get('id')}),
          languages: this.store.findAll('language')
        });
  }

But what is strange, the URL generated by shop-language.js adapter is correct:

http://localhost:4200/shops/613/languages

but I’m still getting the error:

jquery.js:9600 POST http://localhost:4200/shops/613/languages 404 (Not Found)
the backend responded with an error

When I chek the Rails API available routes, everything seems to be correct:

POST   /shops/:shop_id/languages(.:format)     shop_languages#create

Here is the header data I posted from Ember:

data:
{attributes: {modified-by: "Z28SCAMB"},…}
relationships:
{shop: {data: {type: "shops", id: "613"}}, language: {data: {type: "languages", id: "374"}}}
  language:{data: {type: "languages", id: "374"}}
  shop :{data: {type: "shops", id: "613"}}

The problem is that I don’t have language_id value in the params hash and the Language is not found in before_action filter method:

# Rails/shop_langauges_controller:
def find_language
   @language = Language.find_by!(id: params[:language_id])
 end

Why so ?


#9

You also might look in to trying out ember-data-url-templates. With your example, a url template defined on the shop-language adapter like this might do the trick:

createRecordUrlTemplate: '/shops/{shopId}/languages`,

#10

@amiel Thank you for the provided link (it seems like you are the author), I’ll take a look. What make me frustrated the most is the thing that you should either use top-level POST, GET method via Ajax (what is in my opinion almost the same as to hard code URLs), or declare as many adapters as you have models (backend URLs) to hit. I wonder what is the purpose to declare nested routes (with nested templates and Co.) if Ember can’t even figure out which backend URL to hit in this case :frowning:


#11

@amiel, I defined the shop-language.js adapter:

#adapters/shop-language.js

import ApplicationAdapter from './application';
import UrlTemplates from "ember-data-url-templates";

export default ApplicationAdapter.extend(UrlTemplates, {
  urlTemplate: '/shops/{shopId}/languages',
  findAllUrlTemplate: '/shops/{shopId}/languages',
  createRecordUrlTemplate: '/shops/{shopId}/languages',
  deleteRecordUrlTemplate: '/shops/{shopId}/languages/{id}'
});

The routes:

#router.js
Router.map(function() {
  this.route('shops', function(){
    this.route('shop-languages',  { path: ':shop_id/languages'});
  });
});

The template the link-to is defined in:

#templates/shops.hbs
{{#each model as |shop|}}
  {{#link-to 'shops.shop-languages' shop.id}}
    <li>{{shop.name}}</li>
  {{/link-to}}
{{/each}}

When inspecting every link has a correct URL: /shops/1/languages.

The route handler to find all shop languages:

#routes/shops/shop-languages.js

export default Route.extend({
   model(params) {
    return this.store.findAll('shop-language', {shop_id: params.shop_id})
  }
});

When clicking on a shop link to display all its languages, I’m getting the wrong URL error:

GET http://localhost:4200/shops/languages 404 (Not Found)

There is no shop_id added between shops and languages:(. What am I doing wrong here ? I’m using Ember 3. Thank you.


#12

I understand your confusion, and I think this is a common misconception. Unless I’m mistaken, you are confusing the relationship between Ember routing and ember-data. The Ember router defines the hierarchy of the UI (and URLs used to represent the state of the UI) and are not necessarily related to the API one might be using. In your case, it looks like your API has a similar URL structure as your Ember UI. However, it is common for Ember applications to use an API that has a very different URL structure from the UI. This is why ember-data does not automatically reference the URLs defined in your router.js.

It’s honestly been a while and I haven’t used ember-data-url-templates with Ember 3 yet. I’ll take a look and see if I can reproduce so I can hopefully give you correct advice as to what to do. ember-data-url-templates helps with defining URLs with nested routes, but it doesn’t automatically know everything about how relationships should be wired up together.

I also noticed that you are using Rails. If this is a brand new project, you might look in to using http://jsonapi-resources.com, it makes working with JSONAPI Ember adapters really easy.


#13

It looks like that second argument to findAll is not getting passed to ember-data-url-templates (or ember-data-url-templates doesn’t know how to use it (that might be a newer feature; I think that it would be possible to use adapterOptions but I’d need to look in to that more). Anyway, it should work to use query instead, like this:

#routes/shops/shop-languages.js

export default Route.extend({
   model(params) {
    return this.store.query('shop-language', { shopId: params.shop_id });
  }
});

You can see a working example here: https://ember-twiddle.com/50ad61a81d0445109b04b32e7f9cee7d?openFiles=routes.application.js%2C. In this example, the id is inserted between shops and languages. The AJAX request fails because there is no API for this, but hopefully the example is useful.


#14

@amiel Thanks a lot for your responses. I created 2 repos:

  • Rails api project
  • Ember app, using ember-data-url-templates. I tried to use query instead of findAll in the route hander as you suggested:
#routes/shops/shop-languages.js

export default Route.extend({
   model(params) {
    return this.store.query('shop-language', { shopId: params.shop_id });
  }
});

Now the first request was correct: XHR finished loading: GET "http://localhost:4200/shops/3/languages". But it was followed by another one: GET http://localhost:4200/languages/9 404 (Not Found) that failed :(.

What is still not clear for me is:

  • when using Ember Data queries (like findAll, query, etc.), how Ember knows which end-point to hit in the back-end ? It seems like it knows nothing at all about, that’s why I have to override all the URLs to hit or hard-code them using Ajax calls, right ?

  • should I override urlSegments in shop-language.js adapter, like that (or alike):

urlSegments: {
    shop_id: function(type, id, snapshot, query) {
      return snapshot.belongsTo('shop', { id: true });
    }
  },

Thank you.


#15

Finally,I figured out what was the problem.

  1. First issue I fixed was due to the display of language.tag value in a template:
# templates/shops/shop-languages.hbs

<h3> in ShopLanguages template</h3>
{{#each model as |shopLanguage|}}
    <li>{{shopLanguage.language.tag}}</li>
{{/each}}

In this case, on every call language.tag Ember tried to hit /languages/{id} URL because I was getting only shop.shop_languages collection in the Rails backend controller:

#rails-app/app/controllers/shop_languages_controller.rb
class ShopLanguagesController < ApplicationController
  before_action :set_shop

  # GET /shop_languages
  def index
    @shop_languages = @shop.shop_languages

    render json: @shop_languages
  end

To fix that, I had to eager load a language for every shop_language by adding include option to the json to be sent from the above controller:

class ShopLanguagesController < ApplicationController
  before_action :set_shop

  # GET /shop_languages
  def index
    @shop_languages = @shop.shop_languages

    render json: @shop_languages, include: [:language]
  end

And now when clicking on a shop link, I get its languages displayed. I pushed the latest modifications to the above repos on GitHub.

Next step will be to try to add a new language to existing shop languages.