diff --git a/README.rst b/README.rst index e24d689a..df5cc417 100644 --- a/README.rst +++ b/README.rst @@ -187,6 +187,15 @@ api You can use this +api+ method more than once for one method. It could be useful when there are more routes mapped to it. + When providing just one argument (description) or not argument at all, + the paths will be loaded from routes.rb file. + +api! + Provide short description and additional option. + The last parameter is methods short description. + The paths will be loaded from routes.rb file. See + `Rails Routes Integration`_ for more details. + api_versions (also api_version) What version(s) does the action belong to. (See `Versioning`_ for details.) @@ -220,6 +229,12 @@ Example: .. code:: ruby + # The simplest case: just load the paths from routes.rb + api! + def index + end + + # More complex example api :GET, "/users/:id", "Show user profile" error :code => 401, :desc => "Unauthorized" error :code => 404, :desc => "Not Found", :meta => {:anything => "you can think of"} @@ -528,7 +543,13 @@ api_controllers_matcher For reloading to work properly you need to specify where your API controllers are. Can be an array if multiple paths are needed api_routes - Set if your application uses custom API router, different from Rails default + Set if your application uses custom API router, different from Rails + default + +routes_formatter + An object providing the translation from the Rails routes to the + format usable in the documentation when using the `api!` keyword. By + default, the ``Apipie::RoutesFormatter`` is used. markup You can choose markup language for descriptions of your application, @@ -613,6 +634,37 @@ checksum_path update_checksum If set to true, the checksum is recalculated with every documentation_reload call +======================== +Rails Routes Integration +======================== + +Apipie is able to load the information about the paths based on the +routes defined in the Rails application, by using the `api!` keyword +in the DSL. + +It should be usable out of box, however, one might want +to do some customization (such as omitting some implicit parameters in +the path etc.). For this kind of customizations one can create a new +formatter and pass as the ``Apipie.configuration.routes_formatter`` +option, like this: + +.. code:: ruby + + class MyFormatter < Apipie::RailsFormatter + def format_path(route) + super.gsub(/\(.*?\)/, '').gsub('//','') # hide all implicit parameters + end + end + + Apipie.configure do |config| + ... + config.routes_formatter = MyFormatter.new + ... + end + +The similar way can be influenced things like order or a description +of the loaded APIs, even omitting some paths if needed. + ============ Processing ============ diff --git a/lib/apipie/application.rb b/lib/apipie/application.rb index 043ddbda..729b30ed 100644 --- a/lib/apipie/application.rb +++ b/lib/apipie/application.rb @@ -1,4 +1,5 @@ require 'apipie/static_dispatcher' +require 'apipie/routes_formatter' require 'yaml' require 'digest/md5' require 'json' @@ -6,7 +7,6 @@ module Apipie class Application - # we need engine just for serving static assets class Engine < Rails::Engine initializer "static assets" do |app| @@ -29,6 +29,49 @@ def set_resource_id(controller, resource_id) @controller_to_resource_id[controller] = resource_id end + def rails_routes(route_set = nil) + if route_set.nil? && @rails_routes + return @rails_routes + end + route_set ||= Rails.application.routes + # ensure routes are loaded + Rails.application.reload_routes! unless Rails.application.routes.routes.any? + + flatten_routes = [] + + route_set.routes.each do |route| + if route.app.respond_to?(:routes) && route.app.routes.is_a?(ActionDispatch::Routing::RouteSet) + # recursively go though the moutned engines + flatten_routes.concat(rails_routes(route.app.routes)) + else + flatten_routes << route + end + end + + @rails_routes = flatten_routes + end + + # the app might be nested when using contraints, namespaces etc. + # this method does in depth search for the route controller + def route_app_controller(app, route) + if app.respond_to?(:controller) + return app.controller(route.defaults) + elsif app.respond_to?(:app) + return route_app_controller(app.app, route) + end + rescue ActionController::RoutingError + # some errors in the routes will not stop us here: just ignoring + end + + def routes_for_action(controller, method, args) + routes = rails_routes.select do |route| + controller == route_app_controller(route.app, route) && + method.to_s == route.defaults[:action] + end + + Apipie.configuration.routes_formatter.format_routes(routes, args) + end + # create new method api description def define_method_description(controller, method_name, dsl_data) return if ignored?(controller, method_name) diff --git a/lib/apipie/configuration.rb b/lib/apipie/configuration.rb index 5b8d8240..98fc7afc 100644 --- a/lib/apipie/configuration.rb +++ b/lib/apipie/configuration.rb @@ -28,6 +28,12 @@ class Configuration # Api::Engine.routes attr_accessor :api_routes + # a object responsible for transforming the routes loaded from Rails to a form + # to be used in the documentation, when using the `api!` keyword. By default, + # it's Apipie::RoutesFormatter. To customize the behaviour, one can inherit from + # from this class and override the methods as needed. + attr_accessor :routes_formatter + def reload_controllers? @reload_controllers = Rails.env.development? unless defined? @reload_controllers return @reload_controllers && @api_controllers_matcher @@ -158,6 +164,7 @@ def initialize @locale = lambda { |locale| @default_locale } @translate = lambda { |str, locale| str } @persist_show_in_doc = false + @routes_formatter = RoutesFormatter.new end end end diff --git a/lib/apipie/dsl_definition.rb b/lib/apipie/dsl_definition.rb index b8ece982..a0e4d16c 100644 --- a/lib/apipie/dsl_definition.rb +++ b/lib/apipie/dsl_definition.rb @@ -20,7 +20,9 @@ def _apipie_dsl_data_clear def _apipie_dsl_data_init @_apipie_dsl_data = { + :api => false, :api_args => [], + :api_from_routes => nil, :errors => [], :params => [], :resouce_id => nil, @@ -72,16 +74,25 @@ def def_param_group(name, &block) Apipie.add_param_group(self, name, &block) end - # Declare an api. # - # Example: - # api :GET, "/resource_route", "short description", + # # load paths from routes and don't provide description + # api # def api(method, path, desc = nil, options={}) #:doc: return unless Apipie.active_dsl? + _apipie_dsl_data[:api] = true _apipie_dsl_data[:api_args] << [method, path, desc, options] end + # # load paths from routes + # api! "short description", + # + def api!(desc = nil, options={}) #:doc: + return unless Apipie.active_dsl? + _apipie_dsl_data[:api] = true + _apipie_dsl_data[:api_from_routes] = { :desc => desc, :options =>options } + end + # Reference other similar method # # api :PUT, '/articles/:id' @@ -363,22 +374,32 @@ def apipie_concern? # create method api and redefine newly added method def method_added(method_name) #:doc: super + return if !Apipie.active_dsl? || !_apipie_dsl_data[:api] - if ! Apipie.active_dsl? || _apipie_dsl_data[:api_args].blank? - _apipie_dsl_data_clear - return - end + if _apipie_dsl_data[:api_from_routes] + desc = _apipie_dsl_data[:api_from_routes][:desc] + options = _apipie_dsl_data[:api_from_routes][:options] - begin - # remove method description if exists and create new one - Apipie.remove_method_description(self, _apipie_dsl_data[:api_versions], method_name) - description = Apipie.define_method_description(self, method_name, _apipie_dsl_data) - ensure - _apipie_dsl_data_clear + api_from_routes = Apipie.routes_for_action(self, method_name, {:desc => desc, :options => options}).map do |route_info| + [route_info[:verb], + route_info[:path], + route_info[:desc], + (route_info[:options] || {}).merge(:from_routes => true)] + end + _apipie_dsl_data[:api_args].concat(api_from_routes) end + return if _apipie_dsl_data[:api_args].blank? + + # remove method description if exists and create new one + Apipie.remove_method_description(self, _apipie_dsl_data[:api_versions], method_name) + description = Apipie.define_method_description(self, method_name, _apipie_dsl_data) + + _apipie_dsl_data_clear _apipie_define_validators(description) - end # def method_added + ensure + _apipie_dsl_data_clear + end end module Concern @@ -409,18 +430,12 @@ def apipie_concern? def method_added(method_name) #:doc: super - if ! Apipie.active_dsl? || _apipie_dsl_data[:api_args].blank? - _apipie_dsl_data_clear - return - end - - begin - _apipie_concern_data << [method_name, _apipie_dsl_data.merge(:from_concern => true)] - ensure - _apipie_dsl_data_clear - end + return if ! Apipie.active_dsl? || !_apipie_dsl_data[:api] - end # def method_added + _apipie_concern_data << [method_name, _apipie_dsl_data.merge(:from_concern => true)] + ensure + _apipie_dsl_data_clear + end end diff --git a/lib/apipie/method_description.rb b/lib/apipie/method_description.rb index 9b9f289d..44ba2b0a 100644 --- a/lib/apipie/method_description.rb +++ b/lib/apipie/method_description.rb @@ -5,12 +5,13 @@ class MethodDescription class Api - attr_accessor :short_description, :path, :http_method, :options + attr_accessor :short_description, :path, :http_method, :from_routes, :options def initialize(method, path, desc, options) @http_method = method.to_s @path = path @short_description = desc + @from_routes = options[:from_routes] @options = options end @@ -104,7 +105,10 @@ def doc_url end def create_api_url(api) - path = "#{@resource._api_base_url}#{api.path}" + path = api.path + unless api.from_routes + path = "#{@resource._api_base_url}#{path}" + end path = path[0..-2] if path[-1..-1] == '/' return path end diff --git a/lib/apipie/routes_formatter.rb b/lib/apipie/routes_formatter.rb new file mode 100644 index 00000000..5693d233 --- /dev/null +++ b/lib/apipie/routes_formatter.rb @@ -0,0 +1,33 @@ +module Apipie + class RoutesFormatter + API_METHODS = %w{GET POST PUT PATCH OPTIONS DELETE} + + # The entry method called by Apipie to extract the array + # representing the api dsl from the routes definition. + def format_routes(rails_routes, args) + rails_routes.map { |rails_route| format_route(rails_route, args) } + end + + def format_route(rails_route, args) + { :path => format_path(rails_route), + :verb => format_verb(rails_route), + :desc => args[:desc], + :options => args[:options] } + end + + def format_path(rails_route) + rails_route.path.spec.to_s.gsub('(.:format)', '') + end + + def format_verb(rails_route) + verb = API_METHODS.select{|defined_verb| defined_verb =~ /\A#{rails_route.verb}\z/} + if verb.count != 1 + verb = API_METHODS.select{|defined_verb| defined_verb == rails_route.constraints[:method]} + if verb.blank? + raise "Unknow verb #{rails_route.path.spec.to_s}" + end + end + verb.first + end + end +end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 1c596ca4..d5f11445 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -30,6 +30,7 @@ def compare_hashes(h1, h2) it "should contain all resource methods" do methods = subject._methods methods.keys.should include(:show) + methods.keys.should include(:create_route) methods.keys.should include(:index) methods.keys.should include(:create) methods.keys.should include(:update) @@ -382,6 +383,19 @@ def reload_controllers b.full_description.length.should be > 400 end + context "Usign routes.rb" do + it "should contain basic info about method" do + a = Apipie[UsersController, :create_route] + a.apis.count.should == 1 + a.formats.should eq(['json']) + api = a.apis.first + api.short_description.should eq("Create user") + api.path.should eq("/api/users/create_route") + api.from_routes.should be_true + api.http_method.should eq("POST") + end + end + context "contain :see option" do context "the key is valid" do diff --git a/spec/dummy/app/controllers/users_controller.rb b/spec/dummy/app/controllers/users_controller.rb index 3d5dd891..50ae04b7 100644 --- a/spec/dummy/app/controllers/users_controller.rb +++ b/spec/dummy/app/controllers/users_controller.rb @@ -268,4 +268,14 @@ def see_another def desc_from_file render :text => 'document from file action' end + + api! 'Create user' + param_group :user + param :user, Hash do + param :permalink, String + end + param :facts, Hash, :desc => "Additional optional facts about the user", :allow_nil => true + def create_route + end + end diff --git a/spec/dummy/config/initializers/apipie.rb b/spec/dummy/config/initializers/apipie.rb index 1f26998c..a2546bcc 100644 --- a/spec/dummy/config/initializers/apipie.rb +++ b/spec/dummy/config/initializers/apipie.rb @@ -73,7 +73,6 @@ # config.link_extension = "" end - # integer validator class Apipie::Validator::IntegerValidator < Apipie::Validator::BaseValidator diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index e435cfcf..c9722ca9 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -3,7 +3,11 @@ scope ENV['RAILS_RELATIVE_URL_ROOT'] || '/' do scope '/api' do - resources :users + resources :users do + collection do + post :create_route + end + end resources :concerns, :only => [:index, :show] resources :twitter_example do collection do