lib/swagger/grape_swagger_modified.rb
require 'kramdown'
module Grape
class API
class << self
attr_reader :combined_routes, :combined_namespaces
def add_swagger_documentation(options = {})
documentation_class = create_documentation_class
documentation_class.setup({ target_class: self }.merge(options))
mount(documentation_class)
@combined_routes = {}
routes.each do |route|
route_match = route.route_path.split(route.route_prefix).last.match('\/([\w|-]*?)[\.\/\(]')
next if route_match.nil?
resource = route_match.captures.first
next if resource.empty?
resource.downcase!
@combined_routes[resource] ||= []
next if @@hide_documentation_path && route.route_path.include?(@@mount_path)
@combined_routes[resource] << route
end
@combined_namespaces = {}
combine_namespaces(self)
end
private
def combine_namespaces(app)
app.endpoints.each do |endpoint|
ns = if endpoint.respond_to?(:namespace_stackable)
endpoint.namespace_stackable(:namespace).last
else
endpoint.settings.stack.last[:namespace]
end
# use the full namespace here (not the latest level only)
# and strip leading slash
@combined_namespaces[endpoint.namespace.sub(/^\//, '')] = ns if ns
combine_namespaces(endpoint.options[:app]) if endpoint.options[:app]
end
end
def create_documentation_class
Class.new(Grape::API) do
class << self
def name
@@class_name
end
end
def self.setup(options)
defaults = {
target_class: nil,
mount_path: '/swagger_doc',
mount_with_version: false,
base_path: nil,
api_version: '0.1',
markdown: false,
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]
api_version = options[:api_version]
@@mount_path = options[:mount_path] + ((options[:mount_with_version] and api_version.present?) ? "/#{api_version}" : '')
@@class_name = options[:class_name] || options[:mount_path].gsub('/', '')
@@markdown = options[:markdown]
@@hide_format = options[:hide_format]
base_path = options[:base_path]
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
desc api_doc.delete(:desc), params: api_doc.delete(:params)
get @@mount_path do
header['Access-Control-Allow-Origin'] = '*'
header['Access-Control-Request-Method'] = '*'
routes = target_class.combined_routes
namespaces = target_class.combined_namespaces
if @@hide_documentation_path
routes.reject! { |route, _value| "/#{route}/".index(parse_path(@@mount_path, nil) << '/') == 0 }
end
routes_array = routes.keys.map do |local_route|
next if routes[local_route].all?(&:route_hidden)
url_format = '.{format}' unless @@hide_format
description = namespaces[local_route] && namespaces[local_route].options[:desc]
description ||= "Operations about #{local_route.pluralize}"
{
path: "/#{local_route}#{url_format}",
description: description
}
end.compact
output = {
apiVersion: api_version,
swaggerVersion: '1.2',
produces: content_types_for(target_class),
apis: routes_array,
info: 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) || {})
get "#{@@mount_path}/:name" do
header['Access-Control-Allow-Origin'] = '*'
header['Access-Control-Request-Method'] = '*'
models = []
routes = target_class.combined_routes[params[:name]]
error!('Not Found', 404) unless routes
ops = routes.reject(&:route_hidden).group_by do |route|
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 = as_markdown(route.route_notes)
http_codes = parse_http_codes(route.route_http_codes, models)
models << @@models if @@models.present?
models << route.route_entity if route.route_entity.present?
models = 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: parse_header_params(route.route_headers) + parse_params(route.route_params, route.route_path, route.route_method),
type: '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 = parse_entity_name(route.route_entity)
if route.instance_variable_get(:@options)[:is_array]
operation.merge!(
'type' => 'array',
'items' => generate_typeref(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
api_description = {
apiVersion: api_version,
swaggerVersion: '1.2',
resourcePath: "/#{params[:name]}",
produces: content_types_for(target_class),
apis: apis
}
base_path = parse_base_path(base_path, request)
api_description[:basePath] = base_path if base_path && base_path.size > 0 && root_base_path != false
api_description[:models] = parse_entity_models(models) unless models.empty?
api_description[:authorizations] = authorizations if authorizations
api_description
end
end
helpers do
def as_markdown(description)
description && @@markdown ? Kramdown::Document.new(strip_heredoc(description), input: 'GFM', enable_coderay: false).to_html : description
end
def parse_params(params, path, method)
params ||= []
params.map do |param, value|
value[:type] = 'File' if value.is_a?(Hash) && value[:type] == 'Rack::Multipart::UploadedFile'
items = {}
raw_data_type = value.is_a?(Hash) ? (value[:type] || 'string').to_s : 'string'
data_type = case raw_data_type
when 'Boolean', 'Date', 'Integer', 'String'
raw_data_type.downcase
when 'BigDecimal'
'long'
when 'DateTime'
'dateTime'
when 'Numeric'
'double'
else
parse_entity_name(raw_data_type)
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
is_array = value.is_a?(Hash) ? (value[:is_array] || false) : false
enum_values = value.is_a?(Hash) ? value[:values] : nil
enum_values = enum_values.call if enum_values && enum_values.is_a?(Proc)
if value.is_a?(Hash) && value.key?(:param_type)
param_type = value[: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: default_value) if default_value
parsed_params.merge!(enum: enum_values) if enum_values
parsed_params
end
end
def content_types_for(target_class)
content_types = (target_class.settings[:content_types] || {}).values
if content_types.empty?
formats = [target_class.settings[:format], target_class.settings[: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 = 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)
if p.delete(:is_array)
p[:items] = generate_typeref(p[:type])
p[:type] = 'array'
else
p.merge! generate_typeref(p.delete(: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: model.instance_variable_get(:@root) || 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
additional_models = model.exposures.map do |_, config|
config[:using] if config.key?(:documentation)
end.compact
all_models += additional_models
end
all_models
end
def is_primitive?(type)
%w(integer long float double string byte boolean date dateTime).include? type
end
def generate_typeref(type)
if is_primitive? type
{ 'type' => type }
else
{ '$ref' => 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 try(*args, &block)
if args.empty? && block_given?
yield self
elsif respond_to?(args.first)
public_send(*args, &block)
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
end
end
end
end
end
end