Model-Based Subdomain Routes for Rails
In my first article, I described a new rubygem for adding subdomain routing to Rails applications. In this second part, I’ll describe the use of the gem to key subdomain-based routes to your models.
Defining Model-Based Subdomain Routes
The idea here is to have the subdomain of the URL keyed to an ActiveRecord model. Let’s take a hypothetical example of a site which lists items under different categories, each category being represented under its own subdomain. Assume our Category model has a subdomain attribute which contains the category’s custom subdomain. In our routes we’ll still use the subdomain mapper, but instead of specifying one or more subdomains, we just specify a :model option:
ActionController::Routing::Routes.draw do |map| map.subdomain :model => :category do |category| category.resources :items ... end end
As before, the namespace and name prefix for all the nested routes will default to the name of the model (you can override these in the options). The routes will match under any subdomain, and that subdomain will be passed to the controller in the params hash as params[:category_id]. For example, a GET request to dvds.example.com/items will go to the Category::ItemsController#index action with params[:category_id] set to "dvds".
Generating Model-Based Subdomain URLs
So how does URL generation work with these routes? That’s the best bit: just the same way as you’re used to! The routes are fully integrated with your named routes, as well as the form_for, redirect_to and polymorphic_path helpers. The only thing you have to do is make sure your model’s to_param returns the subdomain field for the user:
class Category < ActiveRecord::Base ... def to_param subdomain end ... end
Now, in the above example, let’s say our site has dvd and cd categories, with subdomains dvds and cds. In our controller:
@dvd => #<Category id: 1, subdomain: "dvds", ... > @cd => #<Category id: 2, subdomain: "cds", ... > # when the current host is dvds.example.com: category_items_path(@dvd) => "/items" polymorphic_path [ @dvd, @dvd.items.first ] => "/items/2" category_items_path(@cd) => "http://cds.example.com/items" polymorphic_path [ @cd, @cd.items.first ] => "http://cds.example.com/items/10"
As you can see, the first argument for the named routes (and polymorphic paths) feeds directly into the subdomain for the URL. No more passing :subdomain options. Nice!
ActiveRecord Validations
SubdomainRoutes also gives you a couple of utility validations for your ActiveRecord models:
- validates_subdomain_format_of ensures a subdomain field uses only legal characters in an allowed format; and
- validates_subdomain_not_reserved ensures the field does not take a value already in use by your fixed-subdomain routes.
(Undoubtedly, you’ll want to throw in a validates_uniqueness_of as well.)
Let’s take an example of a site where each user gets a dedicated subdomain. Validations for the subdomain attribute of the User model would be:
class User < ActiveRecord::Base ... validates_subdomain_format_of :subdomain validates_subdomain_not_reserved :subdomain validates_uniqueness_of :subdomain ... end
The library currently uses a simple regexp to limit subdomains to lowercase alphanumeric characters and dashes (except on either end). If you want to conform more precisely to the URI specs, you can override the SubdomainRoutes.valid_subdomain? method and implement your own.
Using Fixed and Model-Based Subdomain Routes Together
Let’s try using fixed and model-based subdomain routes together. Say we want to reserve some subdomains (say support and admin) for administrative functions, with the remainder keyed to user accounts. Our routes:
ActionController::Routing::Routes.draw do |map| map.subdomain :support do |support| ... end map.subdomain :admin do |admin| ... end map.subdomain :model => :user do |user| ... end end
These routes will co-exist quite happily together. We’ve made sure our static subdomain routes are listed first though, so that they get matched first. In the User model we’d add the validations above, which in this case would prevent users from taking www or support as a subdomain. (We could also validate for a minimum and maximum length using validates_length_of.)
Conclusion
So that’s about it for the SubdomainRoutes gem. Give it a go and let me know what you think. It offers a viable alternative to what’s out there at the moment, and some welcome additions. Code is here, if you want to check it out.
where can be problem? :-( I try resolve it already very long time...
Loading development environment (Rails 2.3.2) /Users/pepe/Work/PizzaRiviera/app/models/branch.rb:15:in `alias_method':NameError: undefined method `subdomain' for class `Branch' >> x = Branch.find(1) NameError: undefined method `subdomain' for class `Branch' from /Users/pepe/Work/PizzaRiviera/app/models/branch.rb:15:in `alias_method' from /Users/pepe/Work/PizzaRiviera/app/models/branch.rb:15 from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:380:in `load_without_new_constant_marking' from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:380:in `load_file' from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:521:in `new_constants_in' from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:379:in `load_file' from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:259:in `require_or_load' from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:425:in `load_missing_constant' from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:80:in `const_missing' from /usr/local/lib/ruby/gems/1.8/gems/activesupport-2.3.2/lib/active_support/dependencies.rb:92:in `const_missing' from (irb):1 >> # and now i comment line # alias_method :to_param, :subdomain ?> x = Branch.find(1) => #<Branch id: 1, subdomain: "p1", created_at: "2009-07-14 15:00:59", updated_at: "2009-07-14 15:00:59"> >>I found this solution ... instead snippet from tutorial:
I use:
It would be nice to avoid having to enter first parameter (ie. model_based_subdomain object) in polymorphic_path if you like stay on current subdomain...
@pepe: You are correct! alias_method doesn't work and explicitly defining to_param is the way to go. I've edited the article to reflect that. Thanks!
Great gem, Matthew!
However, in many of my apps I have a set of public routes (e.g. www subdomain) and a whole lot more of stuff scoped to specific accounts identified by subdomains (e.g. model based subdomains). In such a situation, it is unfortunately a bit annoying to always pass the account object into the URL helpers (e.g. account_users_path(current_account), account_products_path(current_account), ...).
Do you have similar applications? If so, how do you create your model-based URLs without passing objects around all day long?
@Niels: I agree that passing the subdomain model instance as the first argument in every URL helper may not be to everyone's taste. You can't forgo the argument altogether though, as then there'd be no way to link to a model-based subdomain URL from, say, a www URL. So you're talking an optional first parameter, which would be pretty difficult to do with the named routes as they stand. With the current code, you're stuck with passing the subdomain model as the first argument to your model-based named routes.
I could envisage adding the ability to pass an option to the subdomain route mapping (say, :internal => true or something like that) to mark them as only for use within that subdomain and not requiring the subdomain model to be passed. But the implementation and interface would need to be carefully considered. I might have a think about it but no guarantees! Feel free to fork the code and add it yourself, of course. It shouldn't necessarily be too hard to do.
Hi Matthew,
maybe you can help me with this error I'm getting.
I'm trying to set SubdomainRoutes up for model-based subdomains with nested resources, so instead of http://example.com/apps/1/members, I can use http://foo.example.com/members (for app#1, nicknamed "foo")
I've set up my routes as
(Old setup commented out)
All functional test pass, but trying to access http://foo.example.dev/members or any other resource nested under apps, throw errors like this
And checking the logs it easy to see the controller param is all messed up
Processing ApplicationController#index (for 127.0.0.1 at 2009-08-01 00:40:17) [GET] Parameters: {"action"=>"index", "controller"=>"app/members", "app_id"=>"foo", "subdomains"=>:app_id}Thanks in advance for any help or hint you can provide me with
@mort: check out http://www.brianjones.ca/archives/2008/12/22/an-error-caused-by-not-missing-a-constant/ which may shed some light on your error.
I suspect your controller code is in the wrong directory or something like that. When you define a subdomain in your routes, the controllers for all those routes are namedspaced to that subdomain by default. For your example, this means that your members controller should be defined in app/controllers/app/members.rb (with the second app directory corresponding to your :app subdomain). Your controller should also be defined as: class App::MembersController << ApplicationController, with the App:: namespacing corresponding to your :app subdomain.
If you don't like this default namespacing behaviour, you can override it by passing :namespace => nil in your map.subdomains call. That way you can keep your controllers in the top app/controllers directory and avoid the namespacing. (Passing :name => will also work, and will cancel the named route prefix as well.)
@Matthew, I had somehow missed all the info about controller namespacing, It seems the :namespace options for map.subdomains was all I needed. Thanks a lot for the gem and your support!
Hi Matthew,
Great gem, thanks for your work on this.
Upgraded to 0.2.3 recently and functionality (and tests) of model-based routes in my app has broken. Absolutely fine in 0.2.2 and 0.2.1. I know you made some internal changes - do I need to update my app in anyway? Basically model-based routes which worked in earlier versions are not being recognised or generated. Thanks in advance for your help!
@simon: Seems like a few people are actually using the gem. I'll be sure to add a changelog for the next version bump! Sorry for the unexpected surprise.
The only thing I changed in 0.2.2 -> 0.2.3 was to enforce the presence of the subdomain model as the first argument in model-based subdomain routes. This was always intended but in some cases you could get away without it. For example:
It used to be that you could call user_posts_path() without a User instance and get a result. (This was unintentional and inconsistent - it wouldn't work for user_post_path(@post), for example.) What you should do is always provide a User instance as the first param e.g. user_posts_path(@user), user_post_path(@user, @post). In 0.2.3 you'll get a "failed to generate" route if you don't. (Check the error message, it should indicate the missing subdomain argument.)
Hopefully this is what you've encountered, and the fix will be easy. If not, shoot me an email with your routes and the errors you're getting, and I'll take a look.
Hi - I am having trouble creating the url to the root page. This is my code:
map.subdomain :model => :sites do |site| site.resources :pages endI try to call:
site_path(@site)But the url that's generated is /:id. When I try
site_page_path(@site, @page)the url looks good - the subdomain is generated correctly. Thoughts?
Also - I have another route declaration that is generating the urls for :new, :create, etc. This is the route generating the 'site_path'.
I feel like I might be missing this - how do you generate the root path for the subdomain?
Sorry - small correction - the url that's generated for
site_path(@site)is /sites/:id
@Mark: the subdomain mapping does not generate any resource-style paths for the subdomain itself. To map the root path for the subdomain, you'd want something like:
(or whatever controller and/or action you want to use for the root path). Your named route would then be site_root_path(@site).
Matthew thanks. I will git this a shot.
I think you are really close to providing future rails standard functionality. Your solution is very elegant.
I really wish I could define the subdomain just like a resource
would generate a :show, :edit, :update, and :destroy method. The :create, :new, and :index would be under the nil subdomain (most likely).
I would want to call <%= link_to @site.nam, @site %> and keep things restful. And I really hope to be able to take advantage of rails polymorphic routes and call url_for [@site, @page]. This currently works even if the site is nil. Without this support, I'll need to add logic outside of the gem, which I'd rather not do.
I started looking into plan B - removing the controller name from the url (to create http://www.example.com/:site_id). I found this post on railsforum.com: http://railsforum.com/viewtopic.php?id=29401. This is often the choice a site makes - to give out dynamic unique names as a top level 'directory' or as a subdomain. This seems like a similar problem.
I am really hoping I don't need to modify my controller or view code. Would you consider making changes to the gem to support this?
- Mark
Thanks for your suggestions. (Glad you're finding the gem useful!)
I did consider having the model-based subdomain mapping generate a set of RESTful routes for the model. I can see how you want to use it, and it's a neat idea, but I decided against it. I'm not sure there's a compelling use case. But more practically, it'd involve a fair bit of extra work to replicate all the map.resource functionality (there's no obvious way that I could see to reuse Rails' own code for this).
So for the time being, it's not on my radar. But I'd welcome any pull requests! ;)
I think the use case is a common one. I'll look into modifying the gem to support it...
Hi Matthew - I looked into adding the support for subdomains with named_routes, but I don't think your gem and the resources named_route generation touch much of the same code. I'm going to keep looking into it, but it may be a different use case than the one you solved here.
BTW. There's a uservoice suggestion for dynamic subdomains on the Rails forum
http://rails.uservoice.com/pages/10012-rails/suggestions/98518-support-for-dynamic-subdomains
Not sure if this is a bug, but I have a www subdomain defined, and then a model subdomain defined.
www.example.com/signup - works fine
some-org.example.com/login - works fine
some-org.example.com/signup - does not work, just as expected
www.example.com/login - loads the login page, when it shouldn't.
How do I stop the www subdomain from thinking the later defined routes for the org model exist for it? Just in case, I checked the DB to make sure there wasn't an Org record with www as the subdomain.
@Steve: Not a bug, this is expected behaviour. Your www.example.com/login url will get routed to the org.login route, with the subdomain appearing in the params hash as :org_id => "www". This is because the model-based subdomain routes don't filter the subdomain at the routing level.
This should not be a problem though. For all your controller actions under the organisation subdomain, you'll be wanting to load the organisation (probably in a before filter) using something like:
So www.example.com/login will raise a 404 at this point, since (presumably) you won't allow an organisations to have 'www' as a subdomain.
You can enforce this in your models using the supplied validation:
This validation will check the model's subdomain attribute against the 'reserved' subdomains which are defined in your routes (in your example, just 'www'), and set an error if there is a conflict.
So no, not a bug, maybe not the path of least surprise would have been better. Defining routes in what appears to be a scoped manner, it's surprising for it to be able to jump out of that scope, that's all. I did already have an org lookup being performed on the subdomain, which of course failed. It's kind of a principle of the thing situation. For when some time in the future or something changes and maybe something doesn't line up, and the request is routed when it shouldn't be.
Also, is there a way to specify unauthorized subdomains, or reserved ones? Just an array. I don't necessarily want to map them to anything. Stuff like similar subdomains(www1, admins, etc...), or inappropriate names?
@Steve: I can see your point. My intention and design for the model-based subdomain maps wasn't to limit or "scope" the subdomain for the contained routes - it was just to have the subdomain appear as a model id in the params. (And for the route generation to work.) I can see how the result might be unexpected though.
But you could also argue that the current behaviour is desirable. How about the case of a special "admin" organisation, which you want to have access to all of the usual organisation routes, but also to special admin-only routes? As it stands you can just add those extra routes under a :admin subdomain mapping, without repeating all the organisation routes.
I just made an easy (one-line!) fix for this in my local repo. I'm going to sit on it for a while though. It might be best implemented as an optional feature in the options hash.
Hey Matthew,
thanks for this gem! :D I just had a look at it and it seems to do pretty much what we need. We, too had the need to filter routes based on subdomain. We accomplished this with Jamis Bucks routing tricks (http://weblog.jamisbuck.org/2006/10/26/monkey-patching-rails-extending-routes-2; we renamed env[:subdomain] to env[:sdomain] to prevent a naming collition) and a subdomain condition:
Now these requests are routed to their corresponding controllers:
http://adam.members.example.com/events => members/events_controller
http://some-band.example.com/events => band/events_controller
http://some-band.example.com/events => Routing Error
We're happy. And really like the explicit filters.
Niko.
Hello Matt! , thanks for this gem , its simply awesome :)
i just get it worked nicely in my site , the only thing i dont know is how to make links that not point to subdomain, url like domain/posts points to subdomain.domain/posts, when i in a subdomain page ...
is there a way to tell the url helper to not to prepend the subdomain in the link ?
best regards
a ha, i get it now ,
i put all the non subdomain routes in
thanks again