Dynamically Adding Nested Resource Routes in Rails
I’m working what feels like a rather large project using the Neo4j.rb gem (which recently had its 3.0 release!). One feature of this project allows users to share different types of events with other users. Access to an endpoint in the API is based on whether a given user has a relationship to the target and, if so, some properties of that relationship. So, for instance, a User who has a direct relationship to an Event with the right score may see some restricted properties; a User who is related to an object that is related to that Event will see some limited properties; a User with no relationship whatsoever will not even be able to get to the page.
One of the nice things about the way this is setup is that all of the relationships share properties and some behavior, so it’s begging to be abstracted out into a module that I can test once and share with my resources. It also means that if I’m not careful, I’ll have to duplicate a lot of basic setup code: routes, controllers, etc,… I want to do this:
class Api::V1::EventsController & ApplicationController has_users_route # normal methods end
…and have it automatically add a `:users` resource under `event`, so I can do `/api/v1/events/:event_id/users/:user_id` and it will route to the `UserSecurity` controller. This will prevent me from having to do this:
namespace :api do namespace :v1 do resources :events do resources :users, to: 'user_security' end resources :bands do resources :users, to: 'user_security' end # repeat about 15 times end end
The question, then, is… how the hell do I do this? I did some research and found a lot of information on using Engines to add routes to apps, but this didn’t feel quite right. Someone on StackOverflow pointed me this. It was different from what I had in mind in that it was registering the new resources directly from `routes.rb`. At first, I didn’t think it was going to fit. I found a way to make it use a method in my controller, celebrated, began writing tests and this post… and realized that it was wiping out all the existing routes and just adding the new ones! Womp womp.
So I abandoned the idea of calling methods from controllers and I’m sticking it in `routes.rb`. This is smarter because (as we already covered) this really is a routing issue. By calling a method from the routes file, I can very easily manage which resources are taking advantage of this feature.
With all that said, my `UserAuthorization` module ended up looking like this:
module UserAuthorization extend ActiveSupport::Concern module ClassMethods def register_new_resource(controller_name) MyApp::Application.routes.draw do puts "Adding #{controller_name}" namespace :api do namespace :v1 do resources controller_name.to_sym do resources :users, controller: 'user_security', param: :given_id end end end end end end end
`routes.rb` looks like this:
['events', 'bands', 'and so on'].each { |resource| ApplicationController.register_new_resource(resource) }
Calling `Rails.application.routes.named_routes.helpers` from the Rails console showed that my new resources were added. Victory! My request specs also suddenly changed and showed that my endpoints had come to life. There was a new problem, though, in the form of `params`.
It’s like this: since UserSecurityController is receiving data from any number of endpoints, I have a mystery resource and mystery param ID: `/api/v1/mystery_resource/:mystery_id/users/:id`. My controller actions need an easy way to get access to each of those and find the appropriate models the user is trying to load.
I started by trying to use the `param` option in the routes like this:
resources controller_name.to_sym, param: :target_id do resources :users, controller: 'user_security', param: :given_id end
All that did was given me `param[:mystery_resource_target_id]` and `param[:given_id]`. The mystery resource — the target the user is trying to modify — was still unidentified, it just had `target_id` appended to it. Some more searches indicated that it might not be possible to change this, so I went in the other direction: If I can’t change the param’s key, I can figure out the path taken that ended up at the controller and set the param accordingly. While I was at it, I added a method to help me find the model that is responsible for the target so I can do things like `target_model.where(whatever)`.
Here’s the resultant class.
class Api::V1::UserSecurityController & ApplicationController before_action :authenticate_user! before_action :target_id private attr_reader :root_resource def target_id @target_id ||= get_target_id end def get_target_id @root_resource = request.fullpath.split('/')[3].singularize params["#{root_resource}_id".to_sym] end def target_model @target_model ||= root_resource.capitalize.constantize end def given_id params[:given_id] end end
I don’t love how I had to do that but it gets the job done and I can and will always refactor. There’s still a lot to do but this is a start. Hope it gives you some ideas.