diff --git a/CHANGELOG.md b/CHANGELOG.md index 33df9171..00577872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ #### Fixes +* [#252](https://github.com/tim-vandecasteele/grape-swagger/pull/252): Allow docs to mounted in separate class than target - [@iangreenleaf](https://github.com/iangreenleaf). * [#251](https://github.com/tim-vandecasteele/grape-swagger/pull/251): Fixed model id equal to model name when root existing in entities - [@aitortomas](https://github.com/aitortomas). * [#232](https://github.com/tim-vandecasteele/grape-swagger/pull/232): Fixed missing raw array params - [@u2](https://github.com/u2). * [#234](https://github.com/tim-vandecasteele/grape-swagger/pull/234): Fixed range :values with float - [@azhi](https://github.com/azhi). diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index 184cfe58..2fd8f9e9 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -1,6 +1,7 @@ require 'grape' require 'grape-swagger/version' require 'grape-swagger/errors' +require 'grape-swagger/doc_methods' require 'grape-swagger/markdown' require 'grape-swagger/markdown/kramdown_adapter' require 'grape-swagger/markdown/redcarpet_adapter' @@ -8,16 +9,19 @@ module Grape class API class << self - attr_reader :combined_routes, :combined_namespaces, :combined_namespace_routes, :combined_namespace_identifiers + attr_accessor :combined_routes, :combined_namespaces, :combined_namespace_routes, :combined_namespace_identifiers def add_swagger_documentation(options = {}) documentation_class = create_documentation_class - documentation_class.setup({ target_class: self }.merge(options)) + options = { target_class: self }.merge(options) + @target_class = options[:target_class] + + documentation_class.setup(options) mount(documentation_class) - @combined_routes = {} - routes.each do |route| + @target_class.combined_routes = {} + @target_class.routes.each do |route| route_path = route.route_path route_match = route_path.split(/^.*?#{route.route_prefix.to_s}/).last next unless route_match @@ -26,20 +30,20 @@ def add_swagger_documentation(options = {}) resource = route_match.captures.first next if resource.empty? resource.downcase! - @combined_routes[resource] ||= [] + @target_class.combined_routes[resource] ||= [] next if documentation_class.hide_documentation_path && route.route_path.include?(documentation_class.mount_path) - @combined_routes[resource] << route + @target_class.combined_routes[resource] << route end - @combined_namespaces = {} - combine_namespaces(self) + @target_class.combined_namespaces = {} + combine_namespaces(@target_class) - @combined_namespace_routes = {} - @combined_namespace_identifiers = {} - combine_namespace_routes(@combined_namespaces) + @target_class.combined_namespace_routes = {} + @target_class.combined_namespace_identifiers = {} + combine_namespace_routes(@target_class.combined_namespaces) - exclusive_route_keys = @combined_routes.keys - @combined_namespaces.keys - exclusive_route_keys.each { |key| @combined_namespace_routes[key] = @combined_routes[key] } + exclusive_route_keys = @target_class.combined_routes.keys - @target_class.combined_namespaces.keys + exclusive_route_keys.each { |key| @target_class.combined_namespace_routes[key] = @target_class.combined_routes[key] } documentation_class end @@ -54,7 +58,7 @@ def combine_namespaces(app) end # use the full namespace here (not the latest level only) # and strip leading slash - @combined_namespaces[endpoint.namespace.sub(/^\//, '')] = ns if ns + @target_class.combined_namespaces[endpoint.namespace.sub(/^\//, '')] = ns if ns combine_namespaces(endpoint.options[:app]) if endpoint.options[:app] end @@ -65,7 +69,7 @@ def combine_namespace_routes(namespaces) namespaces.each do |name, namespace| # get the parent route for the namespace parent_route_name = name.match(%r{^/?([^/]*).*$})[1] - parent_route = @combined_routes[parent_route_name] + parent_route = @target_class.combined_routes[parent_route_name] # fetch all routes that are within the current namespace namespace_routes = parent_route.collect do |route| route if (route.route_path.start_with?(route.route_prefix ? "/#{route.route_prefix}/#{name}" : "/#{name}") || route.route_path.start_with?((route.route_prefix ? "/#{route.route_prefix}/:version/#{name}" : "/:version/#{name}"))) && @@ -79,8 +83,8 @@ def combine_namespace_routes(namespaces) else identifier = name.gsub(/_/, '-').gsub(/\//, '_') end - @combined_namespace_identifiers[identifier] = name - @combined_namespace_routes[identifier] = namespace_routes + @target_class.combined_namespace_identifiers[identifier] = name + @target_class.combined_namespace_routes[identifier] = namespace_routes # get all nested namespaces below the current namespace sub_namespaces = standalone_sub_namespaces(name, namespaces) @@ -92,7 +96,7 @@ def combine_namespace_routes(namespaces) route if sub_ns_paths.include?(route.instance_variable_get(:@options)[:namespace]) || sub_ns_paths_versioned.include?(route.instance_variable_get(:@options)[:namespace]) end.compact # add all determined routes of the sub namespaces to standalone resource - @combined_namespace_routes[identifier].push(*sub_routes) + @target_class.combined_namespace_routes[identifier].push(*sub_routes) else # default case when not explicitly specified or nested == true standalone_namespaces = namespaces.reject { |_, ns| !ns.options.key?(:swagger) || !ns.options[:swagger].key?(:nested) || ns.options[:swagger][:nested] != false } @@ -100,8 +104,8 @@ def combine_namespace_routes(namespaces) # add only to the main route if the namespace is not within any other namespace appearing as standalone resource if parent_standalone_namespaces.empty? # default option, append namespace methods to parent route - @combined_namespace_routes[parent_route_name] = [] unless @combined_namespace_routes.key?(parent_route_name) - @combined_namespace_routes[parent_route_name].push(*namespace_routes) + @target_class.combined_namespace_routes[parent_route_name] = [] unless @target_class.combined_namespace_routes.key?(parent_route_name) + @target_class.combined_namespace_routes[parent_route_name].push(*namespace_routes) end end end @@ -125,7 +129,7 @@ def standalone_sub_namespaces(name, namespaces) def get_non_nested_params(params) # Duplicate the params as we are going to modify them - dup_params = params.each_with_object(Hash.new) do |(param, value), dparams| + dup_params = params.each_with_object({}) do |(param, value), dparams| dparams[param] = value.dup end @@ -172,476 +176,7 @@ def parse_enum_values(values) def create_documentation_class Class.new(Grape::API) do - class << self - def name - @@class_name - end - - def as_markdown(description) - description && @@markdown ? @@markdown.as_markdown(strip_heredoc(description)) : description - end - - def parse_params(params, path, method) - params ||= [] - - parsed_array_params = parse_array_params(params) - - non_nested_parent_params = get_non_nested_params(parsed_array_params) - - non_nested_parent_params.map do |param, value| - items = {} - - raw_data_type = value[:type] if value.is_a?(Hash) - raw_data_type ||= 'string' - data_type = case raw_data_type.to_s - when 'Hash' - 'object' - when 'Rack::Multipart::UploadedFile' - 'File' - when 'Virtus::Attribute::Boolean' - 'boolean' - when 'Boolean', 'Date', 'Integer', 'String', 'Float' - raw_data_type.to_s.downcase - when 'BigDecimal' - 'long' - when 'DateTime' - 'dateTime' - when 'Numeric' - 'double' - when 'Symbol' - 'string' - else - @@documentation_class.parse_entity_name(raw_data_type) - end - - additional_documentation = value.is_a?(Hash) ? value[:documentation] : nil - - if additional_documentation && value.is_a?(Hash) - value = additional_documentation.merge(value) - end - - description = value.is_a?(Hash) ? value[:desc] || value[:description] : '' - required = value.is_a?(Hash) ? !!value[:required] : false - default_value = value.is_a?(Hash) ? value[:default] : nil - example = value.is_a?(Hash) ? value[:example] : nil - is_array = value.is_a?(Hash) ? (value[:is_array] || false) : false - values = value.is_a?(Hash) ? value[:values] : nil - enum_values = parse_enum_values(values) - - if value.is_a?(Hash) && value.key?(:documentation) && value[:documentation].key?(:param_type) - param_type = value[:documentation][:param_type] - if is_array - items = { '$ref' => data_type } - data_type = 'array' - end - else - param_type = case - when path.include?(":#{param}") - 'path' - when %w(POST PUT PATCH).include?(method) - if is_primitive?(data_type) - 'form' - else - 'body' - end - else - 'query' - end - end - name = (value.is_a?(Hash) && value[:full_name]) || param - - parsed_params = { - paramType: param_type, - name: name, - description: as_markdown(description), - type: data_type, - required: required, - allowMultiple: is_array - } - - parsed_params.merge!(format: 'int32') if data_type == 'integer' - parsed_params.merge!(format: 'int64') if data_type == 'long' - parsed_params.merge!(items: items) if items.present? - parsed_params.merge!(defaultValue: example) if example - if default_value && example.blank? - parsed_params.merge!(defaultValue: default_value) - end - parsed_params.merge!(enum: enum_values) if enum_values - parsed_params - end - end - - def content_types_for(target_class) - content_types = (target_class.content_types || {}).values - - if content_types.empty? - formats = [target_class.format, target_class.default_format].compact.uniq - formats = Grape::Formatter::Base.formatters({}).keys if formats.empty? - content_types = Grape::ContentTypes::CONTENT_TYPES.select { |content_type, _mime_type| formats.include? content_type }.values - end - - content_types.uniq - end - - def parse_info(info) - { - contact: info[:contact], - description: as_markdown(info[:description]), - license: info[:license], - licenseUrl: info[:license_url], - termsOfServiceUrl: info[:terms_of_service_url], - title: info[:title] - }.delete_if { |_, value| value.blank? } - end - - def parse_header_params(params) - params ||= [] - - params.map do |param, value| - data_type = 'String' - description = value.is_a?(Hash) ? value[:description] : '' - required = value.is_a?(Hash) ? !!value[:required] : false - default_value = value.is_a?(Hash) ? value[:default] : nil - param_type = 'header' - - parsed_params = { - paramType: param_type, - name: param, - description: as_markdown(description), - type: data_type, - required: required - } - - parsed_params.merge!(defaultValue: default_value) if default_value - - parsed_params - end - end - - def parse_path(path, version) - # adapt format to swagger format - parsed_path = path.gsub('(.:format)', @@hide_format ? '' : '.{format}') - # This is attempting to emulate the behavior of - # Rack::Mount::Strexp. We cannot use Strexp directly because - # all it does is generate regular expressions for parsing URLs. - # TODO: Implement a Racc tokenizer to properly generate the - # parsed path. - parsed_path = parsed_path.gsub(/:([a-zA-Z_]\w*)/, '{\1}') - # add the version - version ? parsed_path.gsub('{version}', version) : parsed_path - end - - def parse_entity_name(model) - if model.respond_to?(:entity_name) - model.entity_name - else - name = model.to_s - entity_parts = name.split('::') - entity_parts.reject! { |p| p == 'Entity' || p == 'Entities' } - entity_parts.join('::') - end - end - - def parse_entity_models(models) - result = {} - models.each do |model| - name = (model.instance_variable_get(:@root) || parse_entity_name(model)) - properties = {} - required = [] - - model.documentation.each do |property_name, property_info| - p = property_info.dup - - required << property_name.to_s if p.delete(:required) - - type = if p[:type] - p.delete(:type) - else - exposure = model.exposures[property_name] - parse_entity_name(exposure[:using]) if exposure - end - - if p.delete(:is_array) - p[:items] = generate_typeref(type) - p[:type] = 'array' - else - p.merge! generate_typeref(type) - end - - # rename Grape Entity's "desc" to "description" - property_description = p.delete(:desc) - p[:description] = property_description if property_description - - # rename Grape's 'values' to 'enum' - select_values = p.delete(:values) - if select_values - select_values = select_values.call if select_values.is_a?(Proc) - p[:enum] = select_values - end - - properties[property_name] = p - end - - result[name] = { - id: name, - properties: properties - } - result[name].merge!(required: required) unless required.empty? - end - - result - end - - def models_with_included_presenters(models) - all_models = models - - models.each do |model| - # get model references from exposures with a documentation - nested_models = model.exposures.map do |_, config| - if config.key?(:documentation) - model = config[:using] - model.respond_to?(:constantize) ? model.constantize : model - end - end.compact - - # get all nested models recursively - additional_models = nested_models.map do |nested_model| - models_with_included_presenters([nested_model]) - end.flatten - - all_models += additional_models - end - - all_models - end - - def is_primitive?(type) - %w(object integer long float double string byte boolean date dateTime).include? type - end - - def generate_typeref(type) - type_s = type.to_s.sub(/^[A-Z]/) { |f| f.downcase } - if is_primitive? type_s - { 'type' => type_s } - else - { '$ref' => parse_entity_name(type) } - end - end - - def parse_http_codes(codes, models) - codes ||= {} - codes.map do |k, v, m| - models << m if m - http_code_hash = { - code: k, - message: v - } - http_code_hash[:responseModel] = parse_entity_name(m) if m - http_code_hash - end - end - - def strip_heredoc(string) - indent = string.scan(/^[ \t]*(?=\S)/).min.try(:size) || 0 - string.gsub(/^[ \t]{#{indent}}/, '') - end - - def parse_base_path(base_path, request) - if base_path.is_a?(Proc) - base_path.call(request) - elsif base_path.is_a?(String) - URI(base_path).relative? ? URI.join(request.base_url, base_path).to_s : base_path - else - request.base_url - end - end - - def hide_documentation_path - @@hide_documentation_path - end - - def mount_path - @@mount_path - end - - def setup(options) - defaults = { - target_class: nil, - mount_path: '/swagger_doc', - base_path: nil, - api_version: '0.1', - markdown: nil, - hide_documentation_path: false, - hide_format: false, - format: nil, - models: [], - info: {}, - authorizations: nil, - root_base_path: true, - api_documentation: { desc: 'Swagger compatible API description' }, - specific_api_documentation: { desc: 'Swagger compatible API description for specific API' } - } - - options = defaults.merge(options) - - target_class = options[:target_class] - @@mount_path = options[:mount_path] - @@class_name = options[:class_name] || options[:mount_path].gsub('/', '') - @@markdown = options[:markdown] ? GrapeSwagger::Markdown.new(options[:markdown]) : nil - @@hide_format = options[:hide_format] - api_version = options[:api_version] - authorizations = options[:authorizations] - root_base_path = options[:root_base_path] - extra_info = options[:info] - api_doc = options[:api_documentation].dup - specific_api_doc = options[:specific_api_documentation].dup - @@models = options[:models] || [] - - @@hide_documentation_path = options[:hide_documentation_path] - - if options[:format] - [:format, :default_format, :default_error_formatter].each do |method| - send(method, options[:format]) - end - end - - @@documentation_class = self - - desc api_doc.delete(:desc), api_doc - get @@mount_path do - header['Access-Control-Allow-Origin'] = '*' - header['Access-Control-Request-Method'] = '*' - - namespaces = target_class.combined_namespaces - namespace_routes = target_class.combined_namespace_routes - - if @@hide_documentation_path - namespace_routes.reject! { |route, _value| "/#{route}/".index(@@documentation_class.parse_path(@@mount_path, nil) << '/') == 0 } - end - - namespace_routes_array = namespace_routes.keys.map do |local_route| - next if namespace_routes[local_route].map(&:route_hidden).all? { |value| value.respond_to?(:call) ? value.call : value } - - url_format = '.{format}' unless @@hide_format - - original_namespace_name = target_class.combined_namespace_identifiers.key?(local_route) ? target_class.combined_namespace_identifiers[local_route] : local_route - description = namespaces[original_namespace_name] && namespaces[original_namespace_name].options[:desc] - description ||= "Operations about #{original_namespace_name.pluralize}" - - { - path: "/#{local_route}#{url_format}", - description: description - } - end.compact - - output = { - apiVersion: api_version, - swaggerVersion: '1.2', - produces: @@documentation_class.content_types_for(target_class), - apis: namespace_routes_array, - info: @@documentation_class.parse_info(extra_info) - } - - output[:authorizations] = authorizations unless authorizations.nil? || authorizations.empty? - - output - end - - desc specific_api_doc.delete(:desc), { params: { - 'name' => { - desc: 'Resource name of mounted API', - type: 'string', - required: true - } - }.merge(specific_api_doc.delete(:params) || {}) }.merge(specific_api_doc) - - get "#{@@mount_path}/:name" do - header['Access-Control-Allow-Origin'] = '*' - header['Access-Control-Request-Method'] = '*' - - models = [] - routes = target_class.combined_namespace_routes[params[:name]] - error!('Not Found', 404) unless routes - - visible_ops = routes.reject do |route| - route.route_hidden.respond_to?(:call) ? route.route_hidden.call : route.route_hidden - end - - ops = visible_ops.group_by do |route| - @@documentation_class.parse_path(route.route_path, api_version) - end - - error!('Not Found', 404) unless ops.any? - - apis = [] - - ops.each do |path, op_routes| - operations = op_routes.map do |route| - notes = @@documentation_class.as_markdown(route.route_notes) - - http_codes = @@documentation_class.parse_http_codes(route.route_http_codes, models) - - models |= @@models if @@models.present? - - models |= Array(route.route_entity) if route.route_entity.present? - - models = @@documentation_class.models_with_included_presenters(models.flatten.compact) - - operation = { - notes: notes.to_s, - summary: route.route_description || '', - nickname: route.route_nickname || (route.route_method + route.route_path.gsub(/[\/:\(\)\.]/, '-')), - method: route.route_method, - parameters: @@documentation_class.parse_header_params(route.route_headers) + @@documentation_class.parse_params(route.route_params, route.route_path, route.route_method), - type: route.route_is_array ? 'array' : 'void' - } - operation[:authorizations] = route.route_authorizations unless route.route_authorizations.nil? || route.route_authorizations.empty? - if operation[:parameters].any? { | param | param[:type] == 'File' } - operation.merge!(consumes: ['multipart/form-data']) - end - operation.merge!(responseMessages: http_codes) unless http_codes.empty? - - if route.route_entity - type = @@documentation_class.parse_entity_name(Array(route.route_entity).first) - if route.route_is_array - operation.merge!(items: { '$ref' => type }) - else - operation.merge!(type: type) - end - end - - operation[:nickname] = route.route_nickname if route.route_nickname - operation - end.compact - apis << { - path: path, - operations: operations - } - end - - # use custom resource naming if available - if target_class.combined_namespace_identifiers.key? params[:name] - resource_path = target_class.combined_namespace_identifiers[params[:name]] - else - resource_path = params[:name] - end - api_description = { - apiVersion: api_version, - swaggerVersion: '1.2', - resourcePath: "/#{resource_path}", - produces: @@documentation_class.content_types_for(target_class), - apis: apis - } - - base_path = @@documentation_class.parse_base_path(options[:base_path], request) - api_description[:basePath] = base_path if base_path && base_path.size > 0 && root_base_path != false - api_description[:models] = @@documentation_class.parse_entity_models(models) unless models.empty? - api_description[:authorizations] = authorizations if authorizations - - api_description - end - end - end + extend GrapeSwagger::DocMethods end end end diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb new file mode 100644 index 00000000..c9e80d43 --- /dev/null +++ b/lib/grape-swagger/doc_methods.rb @@ -0,0 +1,472 @@ +module GrapeSwagger + module DocMethods + def name + @@class_name + end + + def as_markdown(description) + description && @@markdown ? @@markdown.as_markdown(strip_heredoc(description)) : description + end + + def parse_params(params, path, method) + params ||= [] + + parsed_array_params = parse_array_params(params) + + non_nested_parent_params = get_non_nested_params(parsed_array_params) + + non_nested_parent_params.map do |param, value| + items = {} + + raw_data_type = value[:type] if value.is_a?(Hash) + raw_data_type ||= 'string' + data_type = case raw_data_type.to_s + when 'Hash' + 'object' + when 'Rack::Multipart::UploadedFile' + 'File' + when 'Virtus::Attribute::Boolean' + 'boolean' + when 'Boolean', 'Date', 'Integer', 'String', 'Float' + raw_data_type.to_s.downcase + when 'BigDecimal' + 'long' + when 'DateTime' + 'dateTime' + when 'Numeric' + 'double' + when 'Symbol' + 'string' + else + @@documentation_class.parse_entity_name(raw_data_type) + end + + additional_documentation = value.is_a?(Hash) ? value[:documentation] : nil + + if additional_documentation && value.is_a?(Hash) + value = additional_documentation.merge(value) + end + + description = value.is_a?(Hash) ? value[:desc] || value[:description] : '' + required = value.is_a?(Hash) ? !!value[:required] : false + default_value = value.is_a?(Hash) ? value[:default] : nil + example = value.is_a?(Hash) ? value[:example] : nil + is_array = value.is_a?(Hash) ? (value[:is_array] || false) : false + values = value.is_a?(Hash) ? value[:values] : nil + enum_values = parse_enum_values(values) + + if value.is_a?(Hash) && value.key?(:documentation) && value[:documentation].key?(:param_type) + param_type = value[:documentation][:param_type] + if is_array + items = { '$ref' => data_type } + data_type = 'array' + end + else + param_type = case + when path.include?(":#{param}") + 'path' + when %w(POST PUT PATCH).include?(method) + if is_primitive?(data_type) + 'form' + else + 'body' + end + else + 'query' + end + end + name = (value.is_a?(Hash) && value[:full_name]) || param + + parsed_params = { + paramType: param_type, + name: name, + description: as_markdown(description), + type: data_type, + required: required, + allowMultiple: is_array + } + + parsed_params.merge!(format: 'int32') if data_type == 'integer' + parsed_params.merge!(format: 'int64') if data_type == 'long' + parsed_params.merge!(items: items) if items.present? + parsed_params.merge!(defaultValue: example) if example + if default_value && example.blank? + parsed_params.merge!(defaultValue: default_value) + end + parsed_params.merge!(enum: enum_values) if enum_values + parsed_params + end + end + + def content_types_for(target_class) + content_types = (target_class.content_types || {}).values + + if content_types.empty? + formats = [target_class.format, target_class.default_format].compact.uniq + formats = Grape::Formatter::Base.formatters({}).keys if formats.empty? + content_types = Grape::ContentTypes::CONTENT_TYPES.select { |content_type, _mime_type| formats.include? content_type }.values + end + + content_types.uniq + end + + def parse_info(info) + { + contact: info[:contact], + description: as_markdown(info[:description]), + license: info[:license], + licenseUrl: info[:license_url], + termsOfServiceUrl: info[:terms_of_service_url], + title: info[:title] + }.delete_if { |_, value| value.blank? } + end + + def parse_header_params(params) + params ||= [] + + params.map do |param, value| + data_type = 'String' + description = value.is_a?(Hash) ? value[:description] : '' + required = value.is_a?(Hash) ? !!value[:required] : false + default_value = value.is_a?(Hash) ? value[:default] : nil + param_type = 'header' + + parsed_params = { + paramType: param_type, + name: param, + description: as_markdown(description), + type: data_type, + required: required + } + + parsed_params.merge!(defaultValue: default_value) if default_value + + parsed_params + end + end + + def parse_path(path, version) + # adapt format to swagger format + parsed_path = path.gsub('(.:format)', @@hide_format ? '' : '.{format}') + # This is attempting to emulate the behavior of + # Rack::Mount::Strexp. We cannot use Strexp directly because + # all it does is generate regular expressions for parsing URLs. + # TODO: Implement a Racc tokenizer to properly generate the + # parsed path. + parsed_path = parsed_path.gsub(/:([a-zA-Z_]\w*)/, '{\1}') + # add the version + version ? parsed_path.gsub('{version}', version) : parsed_path + end + + def parse_entity_name(model) + if model.respond_to?(:entity_name) + model.entity_name + else + name = model.to_s + entity_parts = name.split('::') + entity_parts.reject! { |p| p == 'Entity' || p == 'Entities' } + entity_parts.join('::') + end + end + + def parse_entity_models(models) + result = {} + models.each do |model| + name = (model.instance_variable_get(:@root) || parse_entity_name(model)) + properties = {} + required = [] + + model.documentation.each do |property_name, property_info| + p = property_info.dup + + required << property_name.to_s if p.delete(:required) + + type = if p[:type] + p.delete(:type) + else + exposure = model.exposures[property_name] + parse_entity_name(exposure[:using]) if exposure + end + + if p.delete(:is_array) + p[:items] = generate_typeref(type) + p[:type] = 'array' + else + p.merge! generate_typeref(type) + end + + # rename Grape Entity's "desc" to "description" + property_description = p.delete(:desc) + p[:description] = property_description if property_description + + # rename Grape's 'values' to 'enum' + select_values = p.delete(:values) + if select_values + select_values = select_values.call if select_values.is_a?(Proc) + p[:enum] = select_values + end + + properties[property_name] = p + end + + result[name] = { + id: name, + properties: properties + } + result[name].merge!(required: required) unless required.empty? + end + + result + end + + def models_with_included_presenters(models) + all_models = models + + models.each do |model| + # get model references from exposures with a documentation + nested_models = model.exposures.map do |_, config| + if config.key?(:documentation) + model = config[:using] + model.respond_to?(:constantize) ? model.constantize : model + end + end.compact + + # get all nested models recursively + additional_models = nested_models.map do |nested_model| + models_with_included_presenters([nested_model]) + end.flatten + + all_models += additional_models + end + + all_models + end + + def is_primitive?(type) + %w(object integer long float double string byte boolean date dateTime).include? type + end + + def generate_typeref(type) + type_s = type.to_s.sub(/^[A-Z]/) { |f| f.downcase } + if is_primitive? type_s + { 'type' => type_s } + else + { '$ref' => parse_entity_name(type) } + end + end + + def parse_http_codes(codes, models) + codes ||= {} + codes.map do |k, v, m| + models << m if m + http_code_hash = { + code: k, + message: v + } + http_code_hash[:responseModel] = parse_entity_name(m) if m + http_code_hash + end + end + + def strip_heredoc(string) + indent = string.scan(/^[ \t]*(?=\S)/).min.try(:size) || 0 + string.gsub(/^[ \t]{#{indent}}/, '') + end + + def parse_base_path(base_path, request) + if base_path.is_a?(Proc) + base_path.call(request) + elsif base_path.is_a?(String) + URI(base_path).relative? ? URI.join(request.base_url, base_path).to_s : base_path + else + request.base_url + end + end + + def hide_documentation_path + @@hide_documentation_path + end + + def mount_path + @@mount_path + end + + def setup(options) + defaults = { + target_class: nil, + mount_path: '/swagger_doc', + base_path: nil, + api_version: '0.1', + markdown: nil, + hide_documentation_path: false, + hide_format: false, + format: nil, + models: [], + info: {}, + authorizations: nil, + root_base_path: true, + api_documentation: { desc: 'Swagger compatible API description' }, + specific_api_documentation: { desc: 'Swagger compatible API description for specific API' } + } + + options = defaults.merge(options) + + target_class = options[:target_class] + @@mount_path = options[:mount_path] + @@class_name = options[:class_name] || options[:mount_path].gsub('/', '') + @@markdown = options[:markdown] ? GrapeSwagger::Markdown.new(options[:markdown]) : nil + @@hide_format = options[:hide_format] + api_version = options[:api_version] + authorizations = options[:authorizations] + root_base_path = options[:root_base_path] + extra_info = options[:info] + api_doc = options[:api_documentation].dup + specific_api_doc = options[:specific_api_documentation].dup + @@models = options[:models] || [] + + @@hide_documentation_path = options[:hide_documentation_path] + + if options[:format] + [:format, :default_format, :default_error_formatter].each do |method| + send(method, options[:format]) + end + end + + @@documentation_class = self + + desc api_doc.delete(:desc), api_doc + get @@mount_path do + header['Access-Control-Allow-Origin'] = '*' + header['Access-Control-Request-Method'] = '*' + + namespaces = target_class.combined_namespaces + namespace_routes = target_class.combined_namespace_routes + + if @@hide_documentation_path + namespace_routes.reject! { |route, _value| "/#{route}/".index(@@documentation_class.parse_path(@@mount_path, nil) << '/') == 0 } + end + + namespace_routes_array = namespace_routes.keys.map do |local_route| + next if namespace_routes[local_route].map(&:route_hidden).all? { |value| value.respond_to?(:call) ? value.call : value } + + url_format = '.{format}' unless @@hide_format + + original_namespace_name = target_class.combined_namespace_identifiers.key?(local_route) ? target_class.combined_namespace_identifiers[local_route] : local_route + description = namespaces[original_namespace_name] && namespaces[original_namespace_name].options[:desc] + description ||= "Operations about #{original_namespace_name.pluralize}" + + { + path: "/#{local_route}#{url_format}", + description: description + } + end.compact + + output = { + apiVersion: api_version, + swaggerVersion: '1.2', + produces: @@documentation_class.content_types_for(target_class), + apis: namespace_routes_array, + info: @@documentation_class.parse_info(extra_info) + } + + output[:authorizations] = authorizations unless authorizations.nil? || authorizations.empty? + + output + end + + desc specific_api_doc.delete(:desc), { params: { + 'name' => { + desc: 'Resource name of mounted API', + type: 'string', + required: true + } + }.merge(specific_api_doc.delete(:params) || {}) }.merge(specific_api_doc) + + get "#{@@mount_path}/:name" do + header['Access-Control-Allow-Origin'] = '*' + header['Access-Control-Request-Method'] = '*' + + models = [] + routes = target_class.combined_namespace_routes[params[:name]] + error!('Not Found', 404) unless routes + + visible_ops = routes.reject do |route| + route.route_hidden.respond_to?(:call) ? route.route_hidden.call : route.route_hidden + end + + ops = visible_ops.group_by do |route| + @@documentation_class.parse_path(route.route_path, api_version) + end + + error!('Not Found', 404) unless ops.any? + + apis = [] + + ops.each do |path, op_routes| + operations = op_routes.map do |route| + notes = @@documentation_class.as_markdown(route.route_notes) + + http_codes = @@documentation_class.parse_http_codes(route.route_http_codes, models) + + models |= @@models if @@models.present? + + models |= Array(route.route_entity) if route.route_entity.present? + + models = @@documentation_class.models_with_included_presenters(models.flatten.compact) + + operation = { + notes: notes.to_s, + summary: route.route_description || '', + nickname: route.route_nickname || (route.route_method + route.route_path.gsub(/[\/:\(\)\.]/, '-')), + method: route.route_method, + parameters: @@documentation_class.parse_header_params(route.route_headers) + @@documentation_class.parse_params(route.route_params, route.route_path, route.route_method), + type: route.route_is_array ? 'array' : 'void' + } + operation[:authorizations] = route.route_authorizations unless route.route_authorizations.nil? || route.route_authorizations.empty? + if operation[:parameters].any? { |param| param[:type] == 'File' } + operation.merge!(consumes: ['multipart/form-data']) + end + operation.merge!(responseMessages: http_codes) unless http_codes.empty? + + if route.route_entity + type = @@documentation_class.parse_entity_name(Array(route.route_entity).first) + if route.route_is_array + operation.merge!(items: { '$ref' => type }) + else + operation.merge!(type: type) + end + end + + operation[:nickname] = route.route_nickname if route.route_nickname + operation + end.compact + apis << { + path: path, + operations: operations + } + end + + # use custom resource naming if available + if target_class.combined_namespace_identifiers.key? params[:name] + resource_path = target_class.combined_namespace_identifiers[params[:name]] + else + resource_path = params[:name] + end + api_description = { + apiVersion: api_version, + swaggerVersion: '1.2', + resourcePath: "/#{resource_path}", + produces: @@documentation_class.content_types_for(target_class), + apis: apis + } + + base_path = @@documentation_class.parse_base_path(options[:base_path], request) + api_description[:basePath] = base_path if base_path && base_path.size > 0 && root_base_path != false + api_description[:models] = @@documentation_class.parse_entity_models(models) unless models.empty? + api_description[:authorizations] = authorizations if authorizations + + api_description + end + end + end +end diff --git a/spec/api_global_models_spec.rb b/spec/api_global_models_spec.rb index 1f774a60..cf314190 100644 --- a/spec/api_global_models_spec.rb +++ b/spec/api_global_models_spec.rb @@ -43,13 +43,13 @@ def app get '/swagger_doc/thing.json' json = JSON.parse(last_response.body) expect(json['models']).to eq( - 'Some::Thing' => { - 'id' => 'Some::Thing', - 'properties' => { - 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, - 'name' => { 'type' => 'string', 'description' => 'Name of something.' } - } - }) + 'Some::Thing' => { + 'id' => 'Some::Thing', + 'properties' => { + 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, + 'name' => { 'type' => 'string', 'description' => 'Name of something.' } + } + }) end it 'uses global models and route endpoint specific entities together' do @@ -57,21 +57,21 @@ def app json = JSON.parse(last_response.body) expect(json['models']).to include( - 'Some::Thing' => { - 'id' => 'Some::Thing', - 'properties' => { - 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, - 'name' => { 'type' => 'string', 'description' => 'Name of something.' } - } - }) + 'Some::Thing' => { + 'id' => 'Some::Thing', + 'properties' => { + 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, + 'name' => { 'type' => 'string', 'description' => 'Name of something.' } + } + }) expect(json['models']).to include( - 'Some::CombinedThing' => { - 'id' => 'Some::CombinedThing', - 'properties' => { - 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, - 'created_at' => { 'type' => 'dateTime', 'description' => 'Creation of something.' } - } - }) + 'Some::CombinedThing' => { + 'id' => 'Some::CombinedThing', + 'properties' => { + 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, + 'created_at' => { 'type' => 'dateTime', 'description' => 'Creation of something.' } + } + }) end end diff --git a/spec/api_models_spec.rb b/spec/api_models_spec.rb index 0eb93345..c0fa2fb2 100644 --- a/spec/api_models_spec.rb +++ b/spec/api_models_spec.rb @@ -227,68 +227,68 @@ def app }, 'required' => ['parts'] - ) + ) expect(result['models']['ComposedOf']).to include( - 'id' => 'ComposedOf', - 'properties' => { - 'part_text' => { - 'type' => 'string', - 'description' => 'Content of composedof.' - } - } - ) + 'id' => 'ComposedOf', + 'properties' => { + 'part_text' => { + 'type' => 'string', + 'description' => 'Content of composedof.' + } + } + ) expect(result['models']['composed']).to include( - 'id' => 'composed', - 'properties' => { - 'part_text' => { - 'type' => 'string', - 'description' => 'Content of composedof else.' - } - - } - ) + 'id' => 'composed', + 'properties' => { + 'part_text' => { + 'type' => 'string', + 'description' => 'Content of composedof else.' + } + + } + ) end it 'includes enum values in params and documentation.' do get '/swagger_doc/enum_description_in_entity' result = JSON.parse(last_response.body) expect(result['models']['EnumValues']).to eq( - 'id' => 'EnumValues', - 'properties' => { - 'gender' => { 'type' => 'string', 'description' => 'Content of something.', 'enum' => %w(Male Female) }, - 'number' => { 'type' => 'integer', 'description' => 'Content of something.', 'enum' => [1, 2] } - } - ) + 'id' => 'EnumValues', + 'properties' => { + 'gender' => { 'type' => 'string', 'description' => 'Content of something.', 'enum' => %w(Male Female) }, + 'number' => { 'type' => 'integer', 'description' => 'Content of something.', 'enum' => [1, 2] } + } + ) expect(result['apis'][0]['operations'][0]).to include( - 'parameters' => - [ - { 'paramType' => 'query', 'name' => 'gender', 'description' => 'Content of something.', 'type' => 'string', 'required' => false, 'allowMultiple' => false, 'enum' => %w(Male Female) }, - { 'paramType' => 'query', 'name' => 'number', 'description' => 'Content of something.', 'type' => 'integer', 'required' => false, 'allowMultiple' => false, 'format' => 'int32', 'enum' => [1, 2] } - ], - 'type' => 'EnumValues' - ) + 'parameters' => + [ + { 'paramType' => 'query', 'name' => 'gender', 'description' => 'Content of something.', 'type' => 'string', 'required' => false, 'allowMultiple' => false, 'enum' => %w(Male Female) }, + { 'paramType' => 'query', 'name' => 'number', 'description' => 'Content of something.', 'type' => 'integer', 'required' => false, 'allowMultiple' => false, 'format' => 'int32', 'enum' => [1, 2] } + ], + 'type' => 'EnumValues' + ) end it 'includes referenced models in those with aliased references.' do get '/swagger_doc/aliasedthing' result = JSON.parse(last_response.body) expect(result['models']['AliasedThing']).to eq( - 'id' => 'AliasedThing', - 'properties' => { - 'post' => { '$ref' => 'Something', 'description' => 'Reference to something.' } - } - ) + 'id' => 'AliasedThing', + 'properties' => { + 'post' => { '$ref' => 'Something', 'description' => 'Reference to something.' } + } + ) expect(result['models']['Something']).to eq( - 'id' => 'Something', - 'properties' => { - 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, - 'links' => { 'type' => 'array', 'items' => { '$ref' => 'link' } } - } - ) + 'id' => 'Something', + 'properties' => { + 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, + 'links' => { 'type' => 'array', 'items' => { '$ref' => 'link' } } + } + ) end it 'includes all entities with four levels of nesting' do diff --git a/spec/api_with_standalone_namespace_spec.rb b/spec/api_with_standalone_namespace_spec.rb index 97ede29f..77e73fe7 100644 --- a/spec/api_with_standalone_namespace_spec.rb +++ b/spec/api_with_standalone_namespace_spec.rb @@ -47,14 +47,14 @@ def app it 'that contains all api paths' do expect(json_body['apis']).to eq( - [ - { 'path' => '/store.{format}', 'description' => 'Operations about stores' }, - { 'path' => '/store_orders.{format}', 'description' => 'Operations about store/orders' }, - { 'path' => '/store_orders_actions2.{format}', 'description' => 'Operations about store/orders/actions2s' }, - { 'path' => '/specific-store-orders.{format}', 'description' => 'Operations about store/:store_id/orders' }, - { 'path' => '/swagger_doc.{format}', 'description' => 'Operations about swagger_docs' } - ] - ) + [ + { 'path' => '/store.{format}', 'description' => 'Operations about stores' }, + { 'path' => '/store_orders.{format}', 'description' => 'Operations about store/orders' }, + { 'path' => '/store_orders_actions2.{format}', 'description' => 'Operations about store/orders/actions2s' }, + { 'path' => '/specific-store-orders.{format}', 'description' => 'Operations about store/:store_id/orders' }, + { 'path' => '/swagger_doc.{format}', 'description' => 'Operations about swagger_docs' } + ] + ) end end @@ -152,14 +152,14 @@ def app it 'that contains all api paths' do expect(json_body['apis']).to eq( - [ - { 'path' => '/store.{format}', 'description' => 'Operations about stores' }, - { 'path' => '/store_orders.{format}', 'description' => 'Operations about store/orders' }, - { 'path' => '/store_orders_actions2.{format}', 'description' => 'Operations about store/orders/actions2s' }, - { 'path' => '/specific-store-orders.{format}', 'description' => 'Operations about store/:store_id/orders' }, - { 'path' => '/swagger_doc.{format}', 'description' => 'Operations about swagger_docs' } - ] - ) + [ + { 'path' => '/store.{format}', 'description' => 'Operations about stores' }, + { 'path' => '/store_orders.{format}', 'description' => 'Operations about store/orders' }, + { 'path' => '/store_orders_actions2.{format}', 'description' => 'Operations about store/orders/actions2s' }, + { 'path' => '/specific-store-orders.{format}', 'description' => 'Operations about store/:store_id/orders' }, + { 'path' => '/swagger_doc.{format}', 'description' => 'Operations about swagger_docs' } + ] + ) end end diff --git a/spec/mounted_target_class_spec.rb b/spec/mounted_target_class_spec.rb new file mode 100644 index 00000000..ae043ec0 --- /dev/null +++ b/spec/mounted_target_class_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +describe 'docs mounted separately from api' do + before :all do + class ActualApi < Grape::API + desc 'Document root' + + desc 'This gets something.', + notes: '_test_' + get '/simple' do + { bla: 'something' } + end + end + + class MountedDocs < Grape::API + add_swagger_documentation(target_class: ActualApi) + end + + class WholeApp < Grape::API + mount ActualApi + mount MountedDocs + end + end + + def app + WholeApp + end + + it 'retrieves docs for actual api class' do + get '/swagger_doc.json' + expect(JSON.parse(last_response.body)).to eq( + 'apiVersion' => '0.1', + 'swaggerVersion' => '1.2', + 'info' => {}, + 'produces' => Grape::ContentTypes::CONTENT_TYPES.values.uniq, + 'apis' => [ + { 'path' => '/simple.{format}', 'description' => 'Operations about simples' } + ] + ) + end + + it 'retrieves docs for endpoint in actual api class' do + get '/swagger_doc/simple.json' + expect(JSON.parse(last_response.body)).to eq( + 'apiVersion' => '0.1', + 'swaggerVersion' => '1.2', + 'basePath' => 'http://example.org', + 'resourcePath' => '/simple', + 'produces' => Grape::ContentTypes::CONTENT_TYPES.values.uniq, + 'apis' => [{ + 'path' => '/simple.{format}', + 'operations' => [{ + 'notes' => '_test_', + 'summary' => 'This gets something.', + 'nickname' => 'GET-simple---format-', + 'method' => 'GET', + 'parameters' => [], + 'type' => 'void' + }] + }] + ) + end +end