More on Full Circle REST

Some time ago I frothed on (hip people call this blogging) about how Rails was missing an opportunity to exploit the modelling work done in routes.rb. You can see my post here.

Anyway, in the intervening month or so, I have been working with Ian White’s nifty ResourcesController plugin. As is my wont, I bitch and moan a lot about what is wrong with ResourcesController (RC). The nice thing is that Ian listens and crafts some pretty damn nice code -a lot of my vision of Full-circle REST is starting to appear in RC. In fact, RC is so promising I have abandoned my own plugin and jumped into bed with Ian (in a manner of speaking).

RC can identify many of the RESTful resources of a concrete request with an increasingly concise invocation. It can handle singleton resources, arbitrary nesting and polymorphic nesting. For these features alone, I suggest you look into it.

RC identifies the abstract resources of the URI by examining the inbound request (it has evolved from doing this in a purely textual fashion to examining the matched route’s structure). But it could be better. It could identify the route (when the request arrives) more efficiently. It could link this concrete route directly and unambiguously to the abstract resource that generated it (keeping RC nice and DRY w.r.t routes.rb). And critically, the abstract resource could refer to its parents in the hierarchy of resources in routes.rb, allowing even more direct determination of the complete chain of abstract resources that is being supplied by the URI. Then we could have full-circle REST.

But at this point, RC is hampered by Rails itself. Three patches to edge are required to get us there. I’ve written the first two, and the third shouldn’t be to hard (volunteers?). I’m presenting them in reverse order because I think they’re easier to understand that way.

Patch #3 - Store the hierarchy of abstract REST resources that generate named routes. Currently, only a few strings (name_prefix) provide circumstantial evidence of the parents. Let’s be explicit and store a reference to the the parent in each ActionController::Resources::Resource instance. Then the abstract REST resources will be represented by a forest of trees -and that is pretty close to ideal.

Patch #2 - Store the generating abstract resource in the generated named route. I’ve written the code to do this, but I’m not proud. In fact, I think it is kinda ugly.

module ActionController
  module Resources
      def action_options_for(action, resource, method = nil)
        default_options = { :action => action.to_s, :resource => resource }
        require_id = !resource.kind_of?(SingletonResource)
        case default_options[:action]
          when “index”, “new” : default_options.merge(add_conditions_for(resource.conditions, method || :get)).merge(resource.requirements)
          when “create”       : default_options.merge(add_conditions_for(resource.conditions, method || :post)).merge(resource.requirements)
          when “show”, “edit” : default_options.merge(add_conditions_for(resource.conditions, method || :get)).merge(resource.requirements(require_id))
          when “update”       : default_options.merge(add_conditions_for(resource.conditions, method || :put)).merge(resource.requirements(require_id))
          when “destroy”      : default_options.merge(add_conditions_for(resource.conditions, method || :delete)).merge(resource.requirements(require_id))
          else                  default_options.merge(add_conditions_for(resource.conditions, method)).merge(resource.requirements)
        end
      end
  end
end

module ActionController
  module Routing
    class Route
      attr_accessor :resource
    end
    class RouteBuilder      # Construct and return a route with the given path and options (including the resource option)
      def build(path, options)        # Wrap the path with slashes
        path = “/#{path}” unless path[0] == ?/
        path = “#{path}/” unless path[-1] == ?/    

        path = “/#{options[:path_prefix]}#{path}” if options[:path_prefix]

        resource = options.delete(:resource)
        segments = segments_for_route_path(path)
        defaults, requirements, conditions = divide_route_options(segments, options)
        requirements = assign_route_options(segments, defaults, requirements)

        route = Route.new
        route.segments = segments
        route.requirements = requirements
        route.conditions = conditions
        route.resource = resource

        if !route.significant_keys.include?(:action) && !route.requirements[:action]
          route.requirements[:action] = “index”
          route.significant_keys << :action
        end

        if !route.significant_keys.include?(:controller)
          raise ArgumentError, “Illegal route: the :controller must be specified!”
        end

        route
      end
    end
  end
end

The actual code change is minimal, but it is not very clean in RouteBuilder -in fact, that code really should not have been required. FYI, this code was working about a month ago and I assume it should still work. Still, beware!

Patch #1 - Store the recognized route in the abstract request. Testing aside (see below), this one is simple:

module ActionController
  class AbstractRequest
    attr_accessor :recognized_route
  end

  module Routing
    class RouteSet
      def recognize(request)
        params, recognized_route = recognize_path(request.path, extract_request_environment(request))
        request.path_parameters = params.with_indifferent_access
	request.recognized_route = recognized_route
        "#{params[:controller].camelize}Controller".constantize
      end

      def recognize_path(path, environment={})
        routes.each do |route|
          result = route.recognize(path, environment) and return result, route
        end

        allows = HTTP_METHODS.select { |verb| routes.find { |r| r.recognize(path, :method => verb) } }

        if environment[:method] && !HTTP_METHODS.include?(environment[:method])
          raise NotImplemented.new(*allows)
        elsif !allows.empty?
          raise MethodNotAllowed.new(*allows)
        else
          raise RoutingError, "No route matches #{path.inspect} with #{environment.inspect}"
        end
      end
    end
  end
end

I’ve changed the return signature of recognize_path so there may be some undesired side-effects. But I haven’t found any yet. Also, the TestRequest object would need to be modified as well to set the recognized_route attribute appropriately because recognize is not invoked in testing (another volunteer?).

So, a quick recap: Rails invites us to model our REST resources (in routes.rb) and uses that information for recognizing inbound routes and generating routes in views. But there is a much value to our modeling work to be found in the controllers themselves. And to harvest that value, we need visibility into the abstract resources that generated the invoked route. Rails needs to evolve in this direction I call full-circle REST.

Technorati Tags:

Leave a Reply

You must be logged in to post a comment.