diff --git a/Gemfile b/Gemfile index 31efb0a1..86901899 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ group :development, :test do gem 'rdoc' gem 'rspec', '~> 3.9' gem 'rubocop', '~> 1.50', require: false + gem "openapi3_parser", "~> 0.10.0" unless ENV['MODEL_PARSER'] == 'grape-swagger-entity' gem 'grape-swagger-entity', git: 'https://github.com/ruby-grape/grape-swagger-entity' diff --git a/README.md b/README.md index d53429e0..1c0fdc2f 100644 --- a/README.md +++ b/README.md @@ -931,7 +931,7 @@ get '/thing', failure: [ # ... end ``` -If no status code is defined [defaults](/lib/grape-swagger/endpoint.rb#L210) would be taken. +If no status code is defined [defaults](/lib/grape-swagger/swagger_2/endpoint.rb#L210) would be taken. The result is then something like following: diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index 76474a19..41c3e272 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -4,12 +4,13 @@ require 'grape-swagger/instance' -require 'grape-swagger/version' -require 'grape-swagger/endpoint' require 'grape-swagger/errors' - -require 'grape-swagger/doc_methods' +require 'grape-swagger/version' require 'grape-swagger/model_parsers' +require 'grape-swagger/swagger_2/endpoint' +require 'grape-swagger/openapi_3/endpoint' +require 'grape-swagger/openapi_3/doc_methods' +require 'grape-swagger/swagger_2/doc_methods' module GrapeSwagger class << self @@ -122,11 +123,10 @@ module SwaggerDocumentationAdder include SwaggerRouting def add_swagger_documentation(options = {}) - documentation_class = create_documentation_class - - version_for(options) options = { target_class: self }.merge(options) + version_for(options) @target_class = options[:target_class] + documentation_class = create_documentation_class(options[:openapi_version]) auth_wrapper = options[:endpoint_auth_wrapper] || Class.new use auth_wrapper if auth_wrapper.method_defined?(:before) && !middleware.flatten.include?(auth_wrapper) @@ -144,6 +144,10 @@ def add_swagger_documentation(options = {}) @target_class.combined_routes = combined_routes @target_class.combined_namespaces = combined_namespaces + endpoint_type = options[:openapi_version] == '3.0' ? Grape::OpenAPI3Endpoint : Grape::Swagger2Endpoint + set_endpoint_type(@target_class, endpoint_type) + set_endpoint_type(documentation_class, endpoint_type) + documentation_class end @@ -153,6 +157,13 @@ def version_for(options) options[:version] = version if version end + def set_endpoint_type(app, klass) + app.endpoints.each do |endpoint| + endpoint.class.include(klass) + set_endpoint_type(endpoint.options[:app], klass) if endpoint.options[:app] + end + end + def combine_namespaces(app) combined_namespaces = {} endpoints = app.endpoints.clone @@ -174,9 +185,13 @@ def combine_namespaces(app) combined_namespaces end - def create_documentation_class + def create_documentation_class(openapi_version) Class.new(GrapeInstance) do - extend GrapeSwagger::DocMethods + if openapi_version == '3.0' + extend GrapeOpenAPI::DocMethods + else + extend GrapeSwagger::DocMethods + end end end end diff --git a/lib/grape-swagger/doc_methods/extensions.rb b/lib/grape-swagger/doc_methods/extensions.rb index 0c5334f7..c4079136 100644 --- a/lib/grape-swagger/doc_methods/extensions.rb +++ b/lib/grape-swagger/doc_methods/extensions.rb @@ -55,9 +55,17 @@ def find_definition(status, path) response = path[method][:responses][status] return if response.nil? - return response[:schema]['$ref'].split('/').last if response[:schema].key?('$ref') + # Swagger 2 + if response[:schema] + return response[:schema]['$ref'].split('/').last if response[:schema].key?('$ref') + return response[:schema]['items']['$ref'].split('/').last if response[:schema].key?('items') + end - response[:schema]['items']['$ref'].split('/').last if response[:schema].key?('items') + # OpenAPI 3 + response[:content].each do |_,v| + return v[:schema]['$ref'].split('/').last if v[:schema].key?('$ref') + return v[:schema]['items']['$ref'].split('/').last if v[:schema].key?('items') + end end def add_extension_to(part, extensions) diff --git a/lib/grape-swagger/doc_methods/parse_params.rb b/lib/grape-swagger/doc_methods/parse_params.rb index c4a1b804..c1078e7e 100644 --- a/lib/grape-swagger/doc_methods/parse_params.rb +++ b/lib/grape-swagger/doc_methods/parse_params.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'grape-swagger/endpoint/info_object_builder' + module GrapeSwagger module DocMethods class ParseParams diff --git a/lib/grape-swagger/endpoint/info_object_builder.rb b/lib/grape-swagger/endpoint/info_object_builder.rb new file mode 100644 index 00000000..07cc396d --- /dev/null +++ b/lib/grape-swagger/endpoint/info_object_builder.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module GrapeSwagger + module Endpoint + class InfoObjectBuilder + attr_reader :infos + + def self.build(infos) + new(infos).build + end + + def initialize(infos) + @infos = infos + end + + def build + result = { + title: infos[:title] || 'API title', + description: infos[:description], + termsOfService: infos[:terms_of_service_url], + contact: contact_object, + license: license_object, + version: infos[:version] + } + + GrapeSwagger::DocMethods::Extensions.add_extensions_to_info(infos, result) + + result.delete_if { |_, value| value.blank? } + end + + private + + # sub-objects of info object + # license + def license_object + { + name: infos[:license], + url: infos[:license_url] + }.delete_if { |_, value| value.blank? } + end + + # contact + def contact_object + { + name: infos[:contact_name], + email: infos[:contact_email], + url: infos[:contact_url] + }.delete_if { |_, value| value.blank? } + end + end + end +end diff --git a/lib/grape-swagger/openapi_3/doc_methods.rb b/lib/grape-swagger/openapi_3/doc_methods.rb new file mode 100644 index 00000000..eacbd31a --- /dev/null +++ b/lib/grape-swagger/openapi_3/doc_methods.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'grape-swagger/doc_methods/status_codes' +require 'grape-swagger/doc_methods/produces_consumes' +require 'grape-swagger/doc_methods/data_type' +require 'grape-swagger/doc_methods/extensions' +require 'grape-swagger/doc_methods/operation_id' +require 'grape-swagger/doc_methods/optional_object' +require 'grape-swagger/doc_methods/path_string' +require 'grape-swagger/doc_methods/tag_name_description' +require 'grape-swagger/openapi_3/doc_methods/parse_params' +require 'grape-swagger/openapi_3/doc_methods/move_params' +require 'grape-swagger/doc_methods/headers' +require 'grape-swagger/doc_methods/build_model_definition' +require 'grape-swagger/doc_methods/version' + +module GrapeOpenAPI + module DocMethods + def hide_documentation_path + @@hide_documentation_path + end + + def mount_path + @@mount_path + end + + def setup(options) + options = defaults.merge(options) + + # options could be set on #add_swagger_documentation call, + # for available options see #defaults + target_class = options[:target_class] + guard = options[:swagger_endpoint_guard] + formatter = options[:format] + api_doc = options[:api_documentation].dup + specific_api_doc = options[:specific_api_documentation].dup + + class_variables_from(options) + + if formatter + %i[format default_format default_error_formatter].each do |method| + send(method, formatter) + end + end + + desc api_doc.delete(:desc), api_doc + + instance_eval(guard) unless guard.nil? + + output_path_definitions = proc do |combi_routes, endpoint| + output = endpoint.swagger_object( + target_class, + endpoint.request, + options + ) + + paths, definitions = endpoint.path_and_definition_objects(combi_routes, target_class, options) + tags = tags_from(paths, options) + + output[:tags] = tags unless tags.empty? || paths.blank? + output[:paths] = paths unless paths.blank? + unless definitions.blank? + output[:components] ||= {} + output[:components][:schemas] = definitions + end + + output + end + + get mount_path do + header['Access-Control-Allow-Origin'] = '*' + header['Access-Control-Request-Method'] = '*' + + output_path_definitions.call(target_class.combined_namespace_routes, self) + end + + desc specific_api_doc.delete(:desc), { params: + specific_api_doc.delete(:params) || {} }.merge(specific_api_doc) + + params do + requires :name, type: String, desc: 'Resource name of mounted API' + optional :locale, type: Symbol, desc: 'Locale of API documentation' + end + + get "#{mount_path}/:name" do + I18n.locale = params[:locale] || I18n.default_locale + + combined_routes = target_class.combined_namespace_routes[params[:name]] + error!({ error: 'named resource not exist' }, 400) if combined_routes.nil? + + output_path_definitions.call({ params[:name] => combined_routes }, self) + end + end + + def defaults + { + info: {}, + models: [], + doc_version: '0.0.1', + target_class: nil, + mount_path: '/swagger_doc', + host: nil, + base_path: nil, + add_base_path: false, + add_version: true, + hide_documentation_path: true, + format: :json, + authorizations: nil, + security_definitions: nil, + security: nil, + api_documentation: { desc: 'Swagger compatible API description' }, + specific_api_documentation: { desc: 'Swagger compatible API description for specific API' }, + endpoint_auth_wrapper: nil, + swagger_endpoint_guard: nil, + token_owner: nil + } + end + + def class_variables_from(options) + @@mount_path = options[:mount_path] + @@class_name = options[:class_name] || options[:mount_path].delete('/') + @@hide_documentation_path = options[:hide_documentation_path] + end + + def tags_from(paths, options) + tags = GrapeSwagger::DocMethods::TagNameDescription.build(paths) + + if options[:tags] + names = options[:tags].map { |t| t[:name] } + tags.reject! { |t| names.include?(t[:name]) } + tags += options[:tags] + end + + tags + end + end +end diff --git a/lib/grape-swagger/openapi_3/doc_methods/move_params.rb b/lib/grape-swagger/openapi_3/doc_methods/move_params.rb new file mode 100644 index 00000000..f7488298 --- /dev/null +++ b/lib/grape-swagger/openapi_3/doc_methods/move_params.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/hash/deep_merge' + +module GrapeSwagger + module DocMethods + class OpenAPIMoveParams + class << self + attr_accessor :definitions + + def can_be_moved?(params, http_verb) + move_methods.include?(http_verb) && includes_body_param?(params) + end + + def to_definition(path, params, route, definitions) + @definitions = definitions + unify!(params) + + params_to_move = movable_params(params) + + return (params + correct_array_param(params_to_move)) if should_correct_array?(params_to_move) + + params << parent_definition_of_params(params_to_move, path, route) + + params + end + + private + + def should_correct_array?(param) + param.length == 1 && param.first[:in] == 'body' && param.first[:type] == 'array' + end + + def correct_array_param(param) + param.first[:schema] = { type: param.first.delete(:type), items: param.first.delete(:items) } + + param + end + + def parent_definition_of_params(params, path, route) + definition_name = OperationId.manipulate(parse_model(path)) + referenced_definition = build_definition(definition_name, params, route.request_method.downcase) + definition = @definitions[referenced_definition] + + move_params_to_new(definition, params) + + definition[:description] = route.description if route.try(:description) + + build_body_parameter(referenced_definition, definition_name, route.options) + end + + def move_params_to_new(definition, params) + params, nested_params = params.partition { |x| !x[:name].to_s.include?('[') } + + unless params.blank? + properties, required = build_properties(params) + add_properties_to_definition(definition, properties, required) + end + + nested_properties = build_nested_properties(nested_params) unless nested_params.blank? + add_properties_to_definition(definition, nested_properties, []) unless nested_params.blank? + end + + def build_properties(params) + properties = {} + required = [] + + prepare_nested_types(params) if should_expose_as_array?(params) + + params.each do |param| + name = param[:name].to_sym + + properties[name] = if should_expose_as_array?([param]) + document_as_array(param) + else + document_as_property(param) + end + + required << name if deletable?(param) && param[:required] + end + + [properties, required] + end + + def document_as_array(param) + {}.tap do |property| + property[:type] = 'array' + property[:description] = param.delete(:description) unless param[:description].nil? + property[:items] = document_as_property(param)[:items] + end + end + + def document_as_property(param) + property_keys.each_with_object({}) do |x, memo| + value = param[x] + value = param[:schema][x] if value.blank? + next if value.blank? + + if x == :type && @definitions[value].present? + memo['$ref'] = "#/components/schemas/#{value}" + else + memo[x] = value + end + end + end + + def build_nested_properties(params, properties = {}) + property = params.bsearch { |x| x[:name].include?('[') }[:name].split('[').first + + nested_params, params = params.partition { |x| x[:name].start_with?("#{property}[") } + prepare_nested_names(property, nested_params) + + recursive_call(properties, property, nested_params) unless nested_params.empty? + build_nested_properties(params, properties) unless params.empty? + + properties + end + + def recursive_call(properties, property, nested_params) + if should_expose_as_array?(nested_params) + properties[property.to_sym] = array_type + move_params_to_new(properties[property.to_sym][:items], nested_params) + else + properties[property.to_sym] = object_type + move_params_to_new(properties[property.to_sym], nested_params) + end + end + + def movable_params(params) + to_delete = params.each_with_object([]) { |x, memo| memo << x if deletable?(x) } + delete_from(params, to_delete) + + to_delete + end + + def delete_from(params, to_delete) + to_delete.each { |x| params.delete(x) } + end + + def add_properties_to_definition(definition, properties, required) + if definition.key?(:items) + definition[:items][:properties].deep_merge!(properties) + add_to_required(definition[:items], required) + else + definition[:properties].deep_merge!(properties) + add_to_required(definition, required) + end + end + + def add_to_required(definition, value) + return if value.blank? + + definition[:required] ||= [] + definition[:required].push(*value) + end + + def build_body_parameter(reference, name, options) + {}.tap do |x| + x[:name] = options[:body_name] || name + x[:in] = 'body' + x[:required] = true + x[:schema] = { '$ref' => "#/components/schemas/#{reference}" } + end + end + + def build_definition(name, params, verb = nil) + name = "#{verb}#{name}" if verb + @definitions[name] = should_expose_as_array?(params) ? array_type : object_type + + name + end + + def array_type + { type: 'array', items: { type: 'object', properties: {} } } + end + + def object_type + { type: 'object', properties: {} } + end + + def prepare_nested_types(params) + params.each do |param| + next unless param[:items] + + param[:schema][:type] = if param[:items][:type] == 'array' + 'string' + elsif param[:items].key?('$ref') + param[:schema][:type] = 'object' + else + param[:items][:type] + end + param[:schema][:format] = param[:items][:format] if param[:items][:format] + param.delete(:items) if param[:schema][:type] != 'object' + end + end + + def prepare_nested_names(property, params) + params.each { |x| x[:name] = x[:name].sub(property, '').sub('[', '').sub(']', '') } + end + + def unify!(params) + params.each { |x| x[:in] = x.delete(:param_type) if x[:param_type] } + params.each { |x| x[:in] = 'body' if x[:in] == 'formData' } if includes_body_param?(params) + end + + def parse_model(ref) + parts = ref.split('/') + parts.last.include?('{') ? parts[0..-2].join('/') : parts[0..-1].join('/') + end + + def property_keys + %i[type format description minimum maximum items enum default] + end + + def deletable?(param) + param[:in] == 'body' + end + + def move_methods + [:post, :put, :patch, 'POST', 'PUT', 'PATCH'] + end + + def includes_body_param?(params) + params.map { |x| return true if x[:in] == 'body' || x[:param_type] == 'body' } + false + end + + def should_expose_as_array?(params) + should_exposed_as(params) == 'array' + end + + def should_exposed_as(params) + params.map { |x| return 'object' if x[:schema][:type] && x[:schema][:type] != 'array' } + 'array' + end + end + end + end +end diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb new file mode 100644 index 00000000..a3811c1b --- /dev/null +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'grape-swagger/doc_methods/parse_params' +require 'grape-swagger/endpoint/info_object_builder' + +module GrapeSwagger + module DocMethods + class OpenAPIParseParams < GrapeSwagger::DocMethods::ParseParams + class << self + private + + def document_type_and_format(settings, data_type) + @parsed_param[:schema] = {} + if DataType.primitive?(data_type) + data = DataType.mapping(data_type) + @parsed_param[:schema][:type], @parsed_param[:schema][:format] = data + else + @parsed_param[:schema][:type] = data_type + end + @parsed_param[:schema][:format] = settings[:format] if settings[:format].present? + end + + def document_array_param(value_type, definitions) + if value_type[:documentation].present? + param_type = value_type[:documentation][:param_type] + doc_type = value_type[:documentation][:type] + type = DataType.mapping(doc_type) if doc_type && !DataType.request_primitive?(doc_type) + collection_format = value_type[:documentation][:collectionFormat] + end + + param_type ||= value_type[:param_type] + + array_items = {} + if definitions[value_type[:data_type]] + array_items['$ref'] = "#/components/schemas/#{@parsed_param[:schema][:type]}" + else + array_items[:type] = type || @parsed_param[:schema][:type] == 'array' ? 'string' : @parsed_param[:schema][:type] + end + array_items[:format] = @parsed_param.delete(:format) if @parsed_param[:format] + + values = value_type[:values] || nil + enum_or_range_values = parse_enum_or_range_values(values) + array_items.merge!(enum_or_range_values) if enum_or_range_values + + array_items[:default] = value_type[:default] if value_type[:default].present? + + @parsed_param[:in] = param_type || 'formData' + @parsed_param[:items] = array_items + @parsed_param[:schema][:type] = 'array' + @parsed_param[:collectionFormat] = collection_format if DataType.collections.include?(collection_format) + end + + def parse_enum_or_range_values(values) + case values + when Proc + parse_enum_or_range_values(values.call) if values.parameters.empty? + when Range + if values.first.is_a?(Numeric) + parse_range_values(values) + else + { enum: values.to_a } + end + else + if values + if values.respond_to? :each + { enum: values } + else + { enum: [values] } + end + end + end + end + end + end + end +end diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb new file mode 100644 index 00000000..0cdaa848 --- /dev/null +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -0,0 +1,409 @@ +# frozen_string_literal: true + +require 'active_support' +require 'active_support/core_ext/string/inflections' +require 'grape-swagger/endpoint/params_parser' + +module Grape + module OpenAPI3Endpoint # rubocop:disable Metrics/ModuleLength + 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.formatters({}).keys if formats.empty? + content_types = Grape::ContentTypes::CONTENT_TYPES.select do |content_type, _mime_type| + formats.include? content_type + end.values + end + + content_types.uniq + end + + # openapi 3.0 related parts + # + # required keys for SwaggerObject + def swagger_object(_target_class, request, options) + url = GrapeSwagger::DocMethods::OptionalObject.build(:host, options, request) + base_path = GrapeSwagger::DocMethods::OptionalObject.build(:base_path, options, request) + servers = if options[:servers] + Array.wrap(options[:servers]) + else + [{ url: "#{request.scheme}://#{url}#{base_path}" }] + end + + object = { + info: GrapeSwagger::Endpoint::InfoObjectBuilder.build(options[:info].merge(version: options[:doc_version])), + openapi: '3.0.0', + security: options[:security], + authorizations: options[:authorizations], + servers: servers + } + + if options[:security_definitions] || options[:security] + components = { securitySchemes: options[:security_definitions] } + components.delete_if { |_, value| value.blank? } + object[:components] = components + end + + GrapeSwagger::DocMethods::Extensions.add_extensions_to_root(options, object) + object.delete_if { |_, value| value.blank? } + end + + # building path and definitions objects + def path_and_definition_objects(namespace_routes, target_class, options) + @content_types = content_types_for(target_class) + + @paths = {} + @definitions = {} + namespace_routes.each_key do |key| + routes = namespace_routes[key] + path_item(routes, options) + end + + add_definitions_from options[:models] + [@paths, @definitions] + end + + def add_definitions_from(models) + return if models.nil? + + models.each { |x| expose_params_from_model(x) } + end + + # path object + def path_item(routes, options) + routes.each do |route| + next if hidden?(route, options) + + @item, path = GrapeSwagger::DocMethods::PathString.build(route, options) + @entity = route.entity || route.options[:success] + + verb, method_object = method_object(route, options, path) + + if @paths.key?(path.to_s) + @paths[path.to_s][verb] = method_object + else + @paths[path.to_s] = { verb => method_object } + end + + GrapeSwagger::DocMethods::Extensions.add(@paths[path.to_s], @definitions, route) + end + end + + def method_object(route, options, path) + method = {} + method[:summary] = summary_object(route) + method[:description] = description_object(route) + + consumes = consumes_object(route, options[:consumes] || options[:format]) + + parameters = params_object(route, options, path, consumes) + .partition { |p| p[:in] == 'body' || p[:in] == 'formData' } + + method[:parameters] = parameters.last + method[:security] = security_object(route) + if %w[POST PUT PATCH].include?(route.request_method) + method[:requestBody] = response_body_object(route, path, consumes, parameters.first) + end + + produces = produces_object(route, options[:produces] || options[:format]) + + method[:responses] = response_object(route, produces) + method[:tags] = route.options.fetch(:tags, tag_object(route, path)) + method[:operationId] = GrapeSwagger::DocMethods::OperationId.build(route, path) + method[:deprecated] = deprecated_object(route) + method.delete_if { |_, value| value.blank? } + + [route.request_method.downcase.to_sym, method] + end + + def deprecated_object(route) + route.options[:deprecated] if route.options.key?(:deprecated) + end + + def security_object(route) + route.options[:security] if route.options.key?(:security) + end + + def summary_object(route) + summary = route.options[:desc] if route.options.key?(:desc) + summary = route.description if route.description.present? && route.options.key?(:detail) + summary = route.options[:summary] if route.options.key?(:summary) + + summary + end + + def description_object(route) + description = route.description if route.description.present? + description = route.options[:detail] if route.options.key?(:detail) + description ||= '' + + description + end + + def produces_object(route, format) + return ['application/octet-stream'] if file_response?(route.attributes.success) && + !route.attributes.produces.present? + + mime_types = GrapeSwagger::DocMethods::ProducesConsumes.call(format) + + route_mime_types = %i[formats content_types produces].map do |producer| + possible = route.options[producer] + GrapeSwagger::DocMethods::ProducesConsumes.call(possible) if possible.present? + end.flatten.compact.uniq + + route_mime_types.present? ? route_mime_types : mime_types + end + + SUPPORTS_CONSUMES = %i[post put patch].freeze + + def consumes_object(route, format) + return unless SUPPORTS_CONSUMES.include?(route.request_method.downcase.to_sym) + + GrapeSwagger::DocMethods::ProducesConsumes.call(route.settings.dig(:description, :consumes) || format) + end + + def params_object(route, options, path, consumes) + parameters = partition_params(route, options).map do |param, value| + value = { required: false }.merge(value) if value.is_a?(Hash) + _, value = default_type([[param, value]]).first if value == '' + if value[:type] + expose_params(value[:type]) + elsif value[:documentation] + expose_params(value[:documentation][:type]) + end + GrapeSwagger::DocMethods::OpenAPIParseParams.call(param, value, path, route, @definitions, consumes) + end + + if GrapeSwagger::DocMethods::OpenAPIMoveParams.can_be_moved?(parameters, route.request_method) + parameters = GrapeSwagger::DocMethods::OpenAPIMoveParams.to_definition(path, parameters, route, @definitions) + end + + parameters + end + + def response_body_object(_, _, consumes, parameters) + file_params, other_params = parameters.partition { |p| p[:schema][:type] == 'file' } + body_params, form_params = other_params.partition { |p| p[:in] == 'body' || p[:schema][:type] == 'json' } + result = consumes.map { |c| response_body_parameter_object(body_params, c) } + + unless form_params.empty? + result << response_body_parameter_object(form_params, 'application/x-www-form-urlencoded') + end + + result << response_body_parameter_object(file_params, 'application/octet-stream') unless file_params.empty? + + { content: result.to_h } + end + + def response_body_parameter_object(parameters, content_type) + properties = parameters.each_with_object({}) do |value, accum| + value[:schema][:type] = 'object' if value[:schema][:type] == 'json' + if value[:schema][:type] == 'file' + value[:schema][:format] = 'binary' + value[:schema][:type] = 'string' + end + accum[value[:name]] = value.except(:name, :in, :required, :schema).merge(value[:schema]) + end + + if properties.values.one? + object_reference = properties.values.first['$ref'] + result = { schema: { '$ref' => object_reference } } + else + result = { schema: { type: :object, properties: properties } } + required_values = parameters.select { |param| param[:required] }.map { |required| required[:name] } + result[:schema][:required] = required_values unless required_values.empty? + end + + [content_type, result] + end + + def response_object(route, content_types) + codes = http_codes_from_route(route) + codes.map! { |x| x.is_a?(Array) ? { code: x[0], message: x[1], model: x[2], examples: x[3], headers: x[4] } : x } + + codes.each_with_object({}) do |value, memo| + value[:message] ||= '' + memo[value[:code]] = { + description: value[:message] + } + + if value[:headers] + value[:headers].each_value do |header| + header[:schema] = { type: header.delete(:type) } + end + memo[value[:code]][:headers] = value[:headers] + end + + if file_response?(value[:model]) + memo[value[:code]][:content] = [content_object(value, value[:model], {}, 'application/octet-stream')].to_h + next + end + + response_model = @item + response_model = expose_params_from_model(value[:model]) if value[:model] + + if memo.key?(200) && route.request_method == 'DELETE' && value[:model].nil? + memo[204] = memo.delete(200) + value[:code] = 204 + end + + next if value[:code] == 204 || value[:code] == 201 + + model = !response_model.start_with?('Swagger_doc') && (@definitions[response_model] || value[:model]) + + ref = build_reference(route, value, response_model) + + memo[value[:code]][:content] = content_types.to_h { |c| content_object(value, model, ref, c) } + + next unless model + + @definitions[response_model][:description] = description_object(route) + end + end + + def content_object(value, model, ref, content_type) + if model + hash = { schema: ref } + if value[:examples] + if value[:examples].keys.length == 1 + hash[:example] = value[:examples].values.first + else + hash[:examples] = value[:examples].transform_values { |v| { value: v } } + end + end + + [content_type, hash] + else + [content_type, {}] + end + end + + def success_code?(code) + status = code.is_a?(Array) ? code.first : code[:code] + status.between?(200, 299) + end + + def http_codes_from_route(route) + if route.http_codes.is_a?(Array) && route.http_codes.any? { |code| success_code?(code) } + route.http_codes.clone + else + success_codes_from_route(route) + (route.http_codes || route.options[:failure] || []) + end + end + + def success_codes_from_route(route) + default_code = GrapeSwagger::DocMethods::StatusCodes.get[route.request_method.downcase.to_sym] + if @entity.is_a?(Hash) + default_code[:code] = @entity[:code] if @entity[:code].present? + default_code[:model] = @entity[:model] if @entity[:model].present? + default_code[:message] = @entity[:message] || route.description || default_code[:message].sub('{item}', @item) + default_code[:examples] = @entity[:examples] if @entity[:examples] + default_code[:headers] = @entity[:headers] if @entity[:headers] + else + default_code = GrapeSwagger::DocMethods::StatusCodes.get[route.request_method.downcase.to_sym] + default_code[:model] = @entity if @entity + default_code[:message] = route.description || default_code[:message].sub('{item}', @item) + end + + [default_code] + end + + def tag_object(route, path) + version = GrapeSwagger::DocMethods::Version.get(route) + version = [version] unless version.is_a?(Array) + Array( + path.split('{')[0].split('/').reject(&:empty?).delete_if do |i| + i == route.prefix.to_s || version.map(&:to_s).include?(i) + end.first + ) + end + + private + + def build_reference(route, value, response_model) + # TODO: proof that the definition exist, if model isn't specified + reference = { '$ref' => "#/components/schemas/#{response_model}" } + route.options[:is_array] && value[:code] < 300 ? { type: 'array', items: reference } : reference + end + + def file_response?(value) + value.to_s.casecmp('file').zero? ? true : false + end + + def build_file_response(memo) + memo['schema'] = { type: 'file' } + end + + def partition_params(route, settings) + declared_params = route.settings[:declared_params] if route.settings[:declared_params].present? + required = merge_params(route) + required = GrapeSwagger::DocMethods::Headers.parse(route) + required unless route.headers.nil? + + default_type(required) + + request_params = unless declared_params.nil? && route.headers.nil? + GrapeSwagger::Endpoint::ParamsParser.parse_request_params(required, settings, self) + end || {} + + request_params.empty? ? required : request_params + end + + def merge_params(route) + param_keys = route.params.keys + route.params.delete_if { |key| key.is_a?(String) && param_keys.include?(key.to_sym) }.to_a + end + + def default_type(params) + default_param_type = { required: true, type: 'Integer' } + params.each { |param| param[-1] = param.last == '' ? default_param_type : param.last } + end + + def expose_params(value) + if value.is_a?(Class) && GrapeSwagger.model_parsers.find(value) + expose_params_from_model(value) + elsif value.is_a?(String) + begin + expose_params(Object.const_get(value.gsub(/\[|\]/, ''))) # try to load class from its name + rescue NameError + nil + end + end + end + + def expose_params_from_model(model) + model = model.is_a?(String) ? model.constantize : model + model_name = model_name(model) + + return model_name if @definitions.key?(model_name) + + @definitions[model_name] = nil + + parser = GrapeSwagger.model_parsers.find(model) + raise GrapeSwagger::Errors::UnregisteredParser, "No parser registered for #{model_name}." unless parser + + properties, required = parser.new(model, self).call + unless properties&.any? + raise GrapeSwagger::Errors::SwaggerSpec, + "Empty model #{model_name}, openapi 3.0 doesn't support empty definitions." + end + + @definitions[model_name] = GrapeSwagger::DocMethods::BuildModelDefinition.build(model, properties, required) + + model_name + end + + def model_name(name) + GrapeSwagger::DocMethods::DataType.parse_entity_name(name) + end + + def hidden?(route, options) + route_hidden = route.settings.try(:[], :swagger).try(:[], :hidden) + route_hidden = route.options[:hidden] if route.options.key?(:hidden) + return route_hidden unless route_hidden.is_a?(Proc) + + options[:token_owner] ? route_hidden.call(send(options[:token_owner].to_sym)) : route_hidden.call + end + end +end diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/swagger_2/doc_methods.rb similarity index 100% rename from lib/grape-swagger/doc_methods.rb rename to lib/grape-swagger/swagger_2/doc_methods.rb diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/swagger_2/endpoint.rb similarity index 93% rename from lib/grape-swagger/endpoint.rb rename to lib/grape-swagger/swagger_2/endpoint.rb index dd93f864..abd550c5 100644 --- a/lib/grape-swagger/endpoint.rb +++ b/lib/grape-swagger/swagger_2/endpoint.rb @@ -5,7 +5,7 @@ require 'grape-swagger/endpoint/params_parser' module Grape - class Endpoint # rubocop:disable Metrics/ClassLength + module Swagger2Endpoint # rubocop:disable Metrics/ModuleLength def content_types_for(target_class) content_types = (target_class.content_types || {}).values @@ -25,7 +25,7 @@ def content_types_for(target_class) # required keys for SwaggerObject def swagger_object(target_class, request, options) object = { - info: info_object(options[:info].merge(version: options[:doc_version])), + info: GrapeSwagger::Endpoint::InfoObjectBuilder.build(options[:info].merge(version: options[:doc_version])), swagger: '2.0', produces: options[:produces] || content_types_for(target_class), consumes: options[:consumes], @@ -41,40 +41,6 @@ def swagger_object(target_class, request, options) object.delete_if { |_, value| value.blank? } end - # building info object - def info_object(infos) - result = { - title: infos[:title] || 'API title', - description: infos[:description], - termsOfService: infos[:terms_of_service_url], - contact: contact_object(infos), - license: license_object(infos), - version: infos[:version] - } - - GrapeSwagger::DocMethods::Extensions.add_extensions_to_info(infos, result) - - result.delete_if { |_, value| value.blank? } - end - - # sub-objects of info object - # license - def license_object(infos) - { - name: infos.delete(:license), - url: infos.delete(:license_url) - }.delete_if { |_, value| value.blank? } - end - - # contact - def contact_object(infos) - { - name: infos.delete(:contact_name), - email: infos.delete(:contact_email), - url: infos.delete(:contact_url) - }.delete_if { |_, value| value.blank? } - end - # building path and definitions objects def path_and_definition_objects(namespace_routes, options) @paths = {} diff --git a/spec/issues/721_set_default_parameter_location_based_on_consumes_spec.rb b/spec/issues/721_set_default_parameter_location_based_on_consumes_spec.rb index c0ce06af..04e0c831 100644 --- a/spec/issues/721_set_default_parameter_location_based_on_consumes_spec.rb +++ b/spec/issues/721_set_default_parameter_location_based_on_consumes_spec.rb @@ -54,7 +54,6 @@ expect(post_schema).to eql( { 'description' => 'create item', 'properties' => { 'logs' => { 'type' => 'string' }, 'phone_number' => { 'format' => 'int32', 'type' => 'integer' } }, 'required' => ['logs'], 'type' => 'object' } ) - puts put_parameters expect(put_parameters).to eql( [{ 'in' => 'path', 'name' => 'id', 'type' => 'integer', 'format' => 'int32', 'required' => true }, { 'in' => 'formData', 'name' => 'logs', 'type' => 'string', 'required' => true }, { 'in' => 'formData', 'name' => 'phone_number', 'type' => 'integer', 'format' => 'int32', 'required' => false }] ) diff --git a/spec/openapi_3/api_openapi_3_components-schemas-models_spec.rb b/spec/openapi_3/api_openapi_3_components-schemas-models_spec.rb new file mode 100644 index 00000000..d65be101 --- /dev/null +++ b/spec/openapi_3/api_openapi_3_components-schemas-models_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'definitions/models' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class ModelApi < Grape::API + format :json + + add_swagger_documentation openapi_version: '3.0', models: [ + ::Entities::UseResponse, + ::Entities::ApiError, + ::Entities::RecursiveModel, + ::Entities::DocumentedHashAndArrayModel + ] + end + end + end + + def app + TheApi::ModelApi + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject).to include 'components' + expect(subject['components']['schemas']).to include(swagger_definitions_models) + end +end diff --git a/spec/openapi_3/api_openapi_3_response_spec.rb b/spec/openapi_3/api_openapi_3_response_spec.rb new file mode 100644 index 00000000..774545b4 --- /dev/null +++ b/spec/openapi_3/api_openapi_3_response_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'response' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class ResponseApi < Grape::API + format :json + + desc 'This returns something', + params: Entities::UseResponse.documentation, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + post '/params_given' do + { 'declared_params' => declared(params) } + end + + desc 'This returns something', + entity: Entities::UseResponse, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + get '/entity_response' do + { 'declared_params' => declared(params) } + end + + desc 'This returns something', + entity: Entities::UseItemResponseAsType, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + get '/nested_type' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::ResponseApi + end + + describe 'uses nested type as response object' do + subject do + get '/swagger_doc/nested_type' + JSON.parse(last_response.body) + end + specify do + expect(subject['paths']['/nested_type']['get']).to eql( + 'description' => 'This returns something', + 'responses' => { + '200' => { + 'description' => 'This returns something', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/UseItemResponseAsType' } + } + } + }, + '400' => { + 'description' => 'NotFound', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + } + } + }, + 'tags' => ['nested_type'], + 'operationId' => 'getNestedType' + ) + expect(subject['components']['schemas']).to eql(swagger_nested_type) + end + end + + describe 'uses entity as response object' do + subject do + get '/swagger_doc/entity_response' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/entity_response']['get']).to eql( + 'description' => 'This returns something', + 'responses' => { + '200' => { + 'description' => 'This returns something', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + } + }, + '400' => { + 'description' => 'NotFound', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + } + } + }, + 'tags' => ['entity_response'], + 'operationId' => 'getEntityResponse' + ) + expect(subject['components']['schemas']).to eql(swagger_entity_as_response_object) + end + end + + describe 'uses params as response object' do + subject do + get '/swagger_doc/params_given' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/params_given']['post']).to eql( + 'description' => 'This returns something', + 'requestBody' => { + 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + '$responses' => { + 'items' => { 'type' => 'string' }, 'type' => 'array' + }, + 'description' => { 'type' => 'string' } + }, + 'type' => 'object' + } + } + } + }, + 'responses' => { + '201' => { + 'description' => 'This returns something' + }, + '400' => { + 'description' => 'NotFound', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + } + } + }, + 'tags' => ['params_given'], + 'operationId' => 'postParamsGiven' + ) + expect(subject['components']['schemas']).to eql(swagger_params_as_response_object) + end + end +end diff --git a/spec/openapi_3/api_swagger_v2_param_type_spec.rb b/spec/openapi_3/api_swagger_v2_param_type_spec.rb new file mode 100644 index 00000000..9c38b5f0 --- /dev/null +++ b/spec/openapi_3/api_swagger_v2_param_type_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'setting of param type, such as `query`, `path`, `formData`, `body`, `header`' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class ParamTypeApi < Grape::API + # using `:param_type` + desc 'full set of request param types', + success: Entities::UseResponse + params do + optional :in_query, type: String, documentation: { param_type: 'query' } + optional :in_header, type: String, documentation: { param_type: 'header' } + end + + get '/defined_param_type' do + { 'declared_params' => declared(params) } + end + + desc 'full set of request param types', + success: Entities::UseResponse + params do + requires :in_path, type: Integer + optional :in_query, type: String, documentation: { param_type: 'query' } + optional :in_header, type: String, documentation: { param_type: 'header' } + end + + get '/defined_param_type/:in_path' do + { 'declared_params' => declared(params) } + end + + desc 'full set of request param types', + success: Entities::UseResponse + params do + optional :in_path, type: Integer + optional :in_query, type: String, documentation: { param_type: 'query' } + optional :in_header, type: String, documentation: { param_type: 'header' } + end + + delete '/defined_param_type/:in_path' do + { 'declared_params' => declared(params) } + end + + # using `:in` + desc 'full set of request param types using `:in`', + success: Entities::UseResponse + params do + optional :in_query, type: String, documentation: { in: 'query' } + optional :in_header, type: String, documentation: { in: 'header' } + end + + get '/defined_in' do + { 'declared_params' => declared(params) } + end + + desc 'full set of request param types using `:in`', + success: Entities::UseResponse + params do + requires :in_path, type: Integer + optional :in_query, type: String, documentation: { in: 'query' } + optional :in_header, type: String, documentation: { in: 'header' } + end + + get '/defined_in/:in_path' do + { 'declared_params' => declared(params) } + end + + desc 'full set of request param types using `:in`' + params do + optional :in_path, type: Integer + optional :in_query, type: String, documentation: { in: 'query' } + optional :in_header, type: String, documentation: { in: 'header' } + end + + delete '/defined_in/:in_path' do + { 'declared_params' => declared(params) } + end + + # file + desc 'file download', + success: Entities::UseResponse + params do + requires :name, type: String + end + + get '/download' do + { 'declared_params' => declared(params) } + end + + desc 'file upload', + success: Entities::UseResponse + params do + requires :name, type: File + end + + post '/upload' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::ParamTypeApi + end + + describe 'foo' do + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/defined_param_type/{in_path}']['delete']['responses']).to eql( + '200' => { + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + }, + 'description' => 'full set of request param types' + } + ) + end + + specify do + expect(subject['paths']['/defined_in/{in_path}']['delete']['responses']).to eql( + '204' => { + 'description' => 'full set of request param types using `:in`' + } + ) + end + end + + describe 'defined param types' do + subject do + get '/swagger_doc/defined_param_type' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/defined_param_type']['get']['parameters']).to eql( + [ + { 'in' => 'query', 'name' => 'in_query', 'required' => false, 'schema' => { 'type' => 'string' } }, + { 'in' => 'header', 'name' => 'in_header', 'required' => false, 'schema' => { 'type' => 'string' } } + ] + ) + end + + specify do + expect(subject['paths']['/defined_param_type/{in_path}']['get']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'in_path', 'required' => true, 'schema' => { 'type' => 'integer', 'format' => 'int32' } }, + { 'in' => 'query', 'name' => 'in_query', 'required' => false, 'schema' => { 'type' => 'string' } }, + { 'in' => 'header', 'name' => 'in_header', 'required' => false, 'schema' => { 'type' => 'string' } } + ] + ) + end + + specify do + expect(subject['paths']['/defined_param_type/{in_path}']['delete']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'in_path', 'required' => true, 'schema' => { 'type' => 'integer', 'format' => 'int32' } }, + { 'in' => 'query', 'name' => 'in_query', 'required' => false, 'schema' => { 'type' => 'string' } }, + { 'in' => 'header', 'name' => 'in_header', 'required' => false, 'schema' => { 'type' => 'string' } } + ] + ) + end + end + + describe 'defined param types with `:in`' do + subject do + get '/swagger_doc/defined_in' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/defined_in']['get']['parameters']).to eql( + [ + { 'in' => 'query', 'name' => 'in_query', 'required' => false, 'schema' => { 'type' => 'string' } }, + { 'in' => 'header', 'name' => 'in_header', 'required' => false, 'schema' => { 'type' => 'string' } } + ] + ) + end + + specify do + expect(subject['paths']['/defined_in/{in_path}']['get']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'in_path', 'required' => true, 'schema' => { 'type' => 'integer', 'format' => 'int32' } }, + { 'in' => 'query', 'name' => 'in_query', 'required' => false, 'schema' => { 'type' => 'string' } }, + { 'in' => 'header', 'name' => 'in_header', 'required' => false, 'schema' => { 'type' => 'string' } } + ] + ) + end + + specify do + expect(subject['paths']['/defined_in/{in_path}']['delete']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'in_path', 'required' => true, 'schema' => { 'type' => 'integer', 'format' => 'int32' } }, + { 'in' => 'query', 'name' => 'in_query', 'required' => false, 'schema' => { 'type' => 'string' } }, + { 'in' => 'header', 'name' => 'in_header', 'required' => false, 'schema' => { 'type' => 'string' } } + ] + ) + end + end + + describe 'file' do + describe 'upload' do + subject do + get '/swagger_doc/upload' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/upload']['post']['requestBody']).to eql( + 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/octet-stream' => { + 'schema' => { + 'properties' => { 'name' => { 'format' => 'binary', 'type' => 'string' } }, + 'required' => ['name'], + 'type' => 'object' + } + } + } + ) + end + end + + describe 'download' do + subject do + get '/swagger_doc/download' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/download']['get']['parameters']).to eql( + [ + { 'in' => 'query', 'name' => 'name', 'required' => true, 'schema' => { 'type' => 'string' } } + ] + ) + end + end + end +end diff --git a/spec/openapi_3/boolean_params_spec.rb b/spec/openapi_3/boolean_params_spec.rb new file mode 100644 index 00000000..b1cbdc37 --- /dev/null +++ b/spec/openapi_3/boolean_params_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Boolean Params' do + def app + Class.new(Grape::API) do + format :json + + params do + requires :a_boolean, type: Virtus::Attribute::Boolean + end + post :splines do + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc/splines' + expect(last_response.status).to eq 200 + body = JSON.parse last_response.body + body['paths']['/splines']['post']['requestBody']['content']['application/x-www-form-urlencoded'] + end + + it 'converts boolean types' do + expect(subject).to eq( + 'schema' => { + 'properties' => { + 'a_boolean' => { 'type' => 'boolean' } + }, + 'required' => ['a_boolean'], + 'type' => 'object' + } + ) + end +end diff --git a/spec/openapi_3/default_api_spec.rb b/spec/openapi_3/default_api_spec.rb new file mode 100644 index 00000000..d3f9d0d4 --- /dev/null +++ b/spec/openapi_3/default_api_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' +# require 'grape_version' + +describe 'Default API' do + context 'with no additional options' do + def app + Class.new(Grape::API) do + format :json + desc 'This gets something.' + get '/something' do + { bla: 'something' } + end + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + it 'documents api' do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [{ 'name' => 'something', 'description' => 'Operations about somethings' }], + 'paths' => { + '/something' => { + 'get' => { + 'description' => 'This gets something.', + 'operationId' => 'getSomething', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'This gets something.' + } + }, + 'tags' => ['something'] + } + } + } + ) + end + + context 'path inside the apis array' do + it 'starts with a forward slash' do + subject['paths'].each do |path| + expect(path.first).to start_with '/' + end + end + end + end + + context 'with additional option block given to desc', if: GrapeVersion.satisfy?('>= 0.12.0') do + def app + Class.new(Grape::API) do + format :json + desc 'This gets something.' + get '/something' do + { bla: 'something' } + end + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc/something' + JSON.parse(last_response.body) + end + + it 'documents endpoint' do + expect(subject).to eq('info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [{ 'name' => 'something', 'description' => 'Operations about somethings' }], + 'paths' => { + '/something' => { + 'get' => { + 'description' => 'This gets something.', + 'operationId' => 'getSomething', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'This gets something.' + } + }, + 'tags' => ['something'] + } + } + }) + end + end + + context 'with additional info' do + def app + Class.new(Grape::API) do + format :json + desc 'This gets something.' + get '/something' do + { bla: 'something' } + end + add_swagger_documentation openapi_version: '3.0', info: { + title: 'My API Title', + description: 'A description of my API', + license: 'Apache 2', + license_url: 'http://test.com', + terms_of_service_url: 'http://terms.com', + contact_email: 'support@test.com', + x: { + logo: 'http://logo.com/img.png' + } + } + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body)['info'] + end + + it 'documents API title' do + expect(subject['title']).to eql('My API Title') + end + + it 'documents API description' do + expect(subject['description']).to eql('A description of my API') + end + + it 'should document the license' do + expect(subject['license']['name']).to eql('Apache 2') + end + + it 'documents the license url' do + expect(subject['license']['url']).to eql('http://test.com') + end + + it 'documents the terms of service url' do + expect(subject['termsOfService']).to eql('http://terms.com') + end + + it 'documents the contact email' do + expect(subject['contact']['email']).to eql('support@test.com') + end + + it 'documents the extension field' do + expect(subject['x-logo']).to eql('http://logo.com/img.png') + end + end + + context 'with tags' do + def app + Class.new(Grape::API) do + format :json + desc 'This gets something.' + get '/something' do + { bla: 'something' } + end + get '/somethingelse' do + { bla: 'somethingelse' } + end + + add_swagger_documentation openapi_version: '3.0', tags: [ + { name: 'something', description: 'customized description' } + ] + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + it 'documents the customized tag' do + expect(subject['tags']).to eql( + [ + { 'name' => 'somethingelse', 'description' => 'Operations about somethingelses' }, + { 'name' => 'something', 'description' => 'customized description' } + ] + ) + end + end +end diff --git a/spec/openapi_3/deprecated_field_spec.rb b/spec/openapi_3/deprecated_field_spec.rb new file mode 100644 index 00000000..0137d406 --- /dev/null +++ b/spec/openapi_3/deprecated_field_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'deprecated endpoint' do + def app + Class.new(Grape::API) do + desc 'Deprecated endpoint', deprecated: true + get '/foobar' do + { foo: 'bar' } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc.json' + JSON.parse(last_response.body) + end + + it 'includes the deprecated field' do + expect(subject['paths']['/foobar']['get']['deprecated']).to eql(true) + end +end diff --git a/spec/openapi_3/description_not_initialized_spec.rb b/spec/openapi_3/description_not_initialized_spec.rb new file mode 100644 index 00000000..e1f373d4 --- /dev/null +++ b/spec/openapi_3/description_not_initialized_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'details' do + describe 'has no description, if details or description are nil' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class GfmRcDetailApi < Grape::API + format :json + + desc nil, + detail: nil, + entity: Entities::UseResponse, + failure: [{ code: 400, model: Entities::ApiError }] + get '/use_gfm_rc_detail' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::GfmRcDetailApi + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/use_gfm_rc_detail']['get']).not_to include('description') + expect(subject['paths']['/use_gfm_rc_detail']['get']['description']).to eql(nil) + end + end +end diff --git a/spec/openapi_3/endpoint_versioned_path_spec.rb b/spec/openapi_3/endpoint_versioned_path_spec.rb new file mode 100644 index 00000000..37abfcab --- /dev/null +++ b/spec/openapi_3/endpoint_versioned_path_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Grape::Endpoint#path_and_definitions' do + let(:item) do + Class.new(Grape::API) do + version 'v1', using: :path + + resource :item do + get '/' + end + end + end + + let(:api) do + item_api = item + + Class.new(Grape::API) do + mount item_api + add_swagger_documentation openapi_version: '3.0', add_version: true + end + end + + let(:options) { { add_version: true } } + let(:target_routes) { api.combined_namespace_routes } + + subject { api.endpoints[0].path_and_definition_objects(target_routes, api, options) } + + it 'is returning a versioned path' do + expect(subject[0].keys[0]).to eq '/v1/item' + end + + it 'tags the endpoint with the resource name' do + expect(subject.first['/v1/item'][:get][:tags]).to eq ['item'] + end + + context 'when custom tags are specified' do + let(:item) do + Class.new(Grape::API) do + version 'v1', using: :path + + resource :item do + desc 'Item description', tags: ['special-item'] + get '/' + end + end + end + + it 'tags the endpoint with the custom tags' do + expect(subject.first['/v1/item'][:get][:tags]).to eq ['special-item'] + end + end +end diff --git a/spec/openapi_3/errors_spec.rb b/spec/openapi_3/errors_spec.rb new file mode 100644 index 00000000..f29dd958 --- /dev/null +++ b/spec/openapi_3/errors_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Errors' do + describe 'Empty model error' do + let!(:app) do + Class.new(Grape::API) do + format :json + + desc 'Empty model get.' do + http_codes [ + { code: 200, message: 'get Empty model', model: EmptyClass } + ] + end + get '/empty_model' do + something = OpenStruct.new text: 'something' + present something, with: EmptyClass + end + + version 'v3', using: :path + add_swagger_documentation openapi_version: '3.0', + api_version: 'v1', + base_path: '/api', + info: { + title: 'The API title to be displayed on the API homepage.', + description: 'A description of the API.', + contact_name: 'Contact name', + contact_email: 'Contact@email.com', + contact_url: 'Contact URL', + license: 'The name of the license.', + license_url: 'www.The-URL-of-the-license.org', + terms_of_service_url: 'www.The-URL-of-the-terms-and-service.com' + } + end + end + + it 'should raise SwaggerSpec exception' do + expect { get '/v3/swagger_doc' }.to raise_error(GrapeSwagger::Errors::SwaggerSpec, "Empty model EmptyClass, openapi 3.0 doesn't support empty definitions.") + end + end + + describe 'Parser not found error' do + let!(:app) do + Class.new(Grape::API) do + format :json + + desc 'Wrong model get.' do + http_codes [ + { code: 200, message: 'get Wrong model', model: Hash } + ] + end + get '/wrong_model' do + something = OpenStruct.new text: 'something' + present something, with: Hash + end + + version 'v3', using: :path + add_swagger_documentation openapi_version: '3.0', + api_version: 'v1', + base_path: '/api', + info: { + title: 'The API title to be displayed on the API homepage.', + description: 'A description of the API.', + contact_name: 'Contact name', + contact_email: 'Contact@email.com', + contact_url: 'Contact URL', + license: 'The name of the license.', + license_url: 'www.The-URL-of-the-license.org', + terms_of_service_url: 'www.The-URL-of-the-terms-and-service.com' + } + end + end + + it 'should raise UnregisteredParser exception' do + expect { get '/v3/swagger_doc' }.to raise_error(GrapeSwagger::Errors::UnregisteredParser, 'No parser registered for Hash.') + end + end +end diff --git a/spec/openapi_3/float_api_spec.rb b/spec/openapi_3/float_api_spec.rb new file mode 100644 index 00000000..6eecc181 --- /dev/null +++ b/spec/openapi_3/float_api_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Float Params' do + def app + Class.new(Grape::API) do + format :json + + params do + requires :a_float, type: Float + end + post :splines do + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc/splines' + expect(last_response.status).to eq 200 + body = JSON.parse last_response.body + body['paths']['/splines']['post']['requestBody']['content']['application/x-www-form-urlencoded'] + end + + it 'converts float types' do + expect(subject).to eq( + 'schema' => { + 'properties' => { + 'a_float' => { 'format' => 'float', 'type' => 'number' } + }, + 'required' => ['a_float'], + 'type' => 'object' + } + ) + end +end diff --git a/spec/openapi_3/form_params_spec.rb b/spec/openapi_3/form_params_spec.rb new file mode 100644 index 00000000..0a0e83e7 --- /dev/null +++ b/spec/openapi_3/form_params_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Form Params' do + def app + Class.new(Grape::API) do + format :json + + params do + requires :name, type: String, desc: 'name of item' + end + post '/items' do + {} + end + + params do + requires :id, type: Integer, desc: 'id of item' + requires :name, type: String, desc: 'name of item' + requires :conditions, type: Integer, desc: 'conditions of item', values: [1, 2, 3] + end + put '/items/:id' do + {} + end + + params do + requires :id, type: Integer, desc: 'id of item' + requires :name, type: String, desc: 'name of item' + optional :conditions, type: String, desc: 'conditions of item', values: proc { %w[1 2] } + end + patch '/items/:id' do + {} + end + + params do + requires :id, type: Integer, desc: 'id of item' + requires :name, type: String, desc: 'name of item' + optional :conditions, type: Symbol, desc: 'conditions of item', values: %i[one two] + end + post '/items/:id' do + {} + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc/items' + JSON.parse(last_response.body) + end + + it 'retrieves the documentation form params' do + expect(subject['paths'].length).to eq 2 + expect(subject['paths'].keys).to include('/items', '/items/{id}') + expect(subject['paths']['/items'].keys).to include 'post' + expect(subject['paths']['/items/{id}'].keys).to include('post', 'patch', 'put') + end + + it 'treats Symbol parameter as form param' do + expect(subject['paths']['/items/{id}']['post']['parameters']).to eq [{ + 'in' => 'path', + 'name' => 'id', + 'description' => 'id of item', + 'required' => true, + 'schema' => { 'type' => 'integer', 'format' => 'int32' } + }] + + expect(subject['paths']['/items/{id}']['post']['requestBody']).to eq 'content' => { + 'application/json' => { + 'schema' => { + '$ref' => '#/components/schemas/postItems' + } + } + } + + expect(subject['components']['schemas']['postItems']).to match( + 'properties' => { + 'conditions' => { + 'description' => 'conditions of item', + 'enum' => %w[one two], + 'type' => 'string' + }, + 'name' => { + 'description' => 'name of item', + 'type' => 'string' + } + }, + 'required' => ['name'], + 'type' => 'object' + ) + end +end diff --git a/spec/openapi_3/guarded_endpoint_spec.rb b/spec/openapi_3/guarded_endpoint_spec.rb new file mode 100644 index 00000000..4bd1f66a --- /dev/null +++ b/spec/openapi_3/guarded_endpoint_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +class SampleAuth < Grape::Middleware::Base + module AuthMethods + attr_accessor :access_token + + def protected_endpoint=(protected) + @protected_endpoint = protected + end + + def protected_endpoint? + @protected_endpoint || false + end + + def resource_owner + @resource_owner = true if access_token == '12345' + end + end + + def context + env['api.endpoint'] + end + + def before + context.extend(SampleAuth::AuthMethods) + context.protected_endpoint = context.options[:route_options][:auth].present? + + return unless context.protected_endpoint? + + scopes = context.options[:route_options][:auth][:scopes] + authorize!(*scopes) unless scopes.include? false + context.access_token = env['HTTP_AUTHORIZATION'] + end +end + +module Extension + def sample_auth(*scopes) + description = route_setting(:description) || route_setting(:description, {}) + description[:auth] = { scopes: scopes } + end + + GrapeInstance.extend self +end + +describe 'a guarded api endpoint' do + before :all do + class GuardedMountedApi < Grape::API + resource_owner_valid = proc { |token_owner = nil| token_owner.nil? } + + desc 'Show endpoint if authenticated' + route_setting :swagger, hidden: resource_owner_valid + get '/auth' do + { foo: 'bar' } + end + end + + class GuardedApi < Grape::API + mount GuardedMountedApi + add_swagger_documentation openapi_version: '3.0', + endpoint_auth_wrapper: SampleAuth, + swagger_endpoint_guard: 'sample_auth false', + token_owner: 'resource_owner' + end + end + + def app + GuardedApi + end + + context 'when a correct token is passed with the request' do + subject do + get '/swagger_doc.json', {}, 'HTTP_AUTHORIZATION' => '12345' + JSON.parse(last_response.body) + end + + it 'retrieves swagger-documentation for the endpoint' do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [{ 'name' => 'auth', 'description' => 'Operations about auths' }], + 'paths' => { + '/auth' => { + 'get' => { + 'description' => 'Show endpoint if authenticated', + 'operationId' => 'getAuth', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'Show endpoint if authenticated' + } + }, + 'tags' => ['auth'] + } + } + } + ) + end + end + + context 'when a bad token is passed with the request' do + subject do + get '/swagger_doc.json', {}, 'HTTP_AUTHORIZATION' => '123456' + JSON.parse(last_response.body) + end + + it 'does not retrieve swagger-documentation for the endpoint - only the info_object' do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }] + ) + end + end +end diff --git a/spec/openapi_3/hide_api_spec.rb b/spec/openapi_3/hide_api_spec.rb new file mode 100644 index 00000000..754f9184 --- /dev/null +++ b/spec/openapi_3/hide_api_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'a hide mounted api' do + before :all do + class HideMountedApi < Grape::API + desc 'Show this endpoint' + get '/simple' do + { foo: 'bar' } + end + + desc 'Hide this endpoint', hidden: true + get '/hide' do + { foo: 'bar' } + end + + desc 'Hide this endpoint using route setting' + route_setting :swagger, hidden: true + get '/hide_as_well' do + { foo: 'bar' } + end + + desc 'Lazily show endpoint', hidden: -> { false } + get '/lazy' do + { foo: 'bar' } + end + end + + class HideApi < Grape::API + mount HideMountedApi + add_swagger_documentation openapi_version: '3.0' + end + end + + def app + HideApi + end + + subject do + get '/swagger_doc.json' + JSON.parse(last_response.body) + end + + it "retrieves swagger-documentation that doesn't include hidden endpoints" do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [ + { 'name' => 'simple', 'description' => 'Operations about simples' }, + { 'name' => 'lazy', 'description' => 'Operations about lazies' } + ], + 'paths' => { + '/lazy' => { + 'get' => { + 'description' => 'Lazily show endpoint', + 'operationId' => 'getLazy', + 'responses' => { '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'Lazily show endpoint' + } }, + 'tags' => ['lazy'] + } + }, + '/simple' => { + 'get' => { + 'description' => 'Show this endpoint', + 'operationId' => 'getSimple', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'Show this endpoint' + } + }, + 'tags' => ['simple'] + } + } + } + ) + end +end + +describe 'a hide mounted api with same namespace' do + before :all do + class HideNamespaceMountedApi < Grape::API + desc 'Show this endpoint' + get '/simple/show' do + { foo: 'bar' } + end + + desc 'Hide this endpoint', hidden: true + get '/simple/hide' do + { foo: 'bar' } + end + + desc 'Lazily hide endpoint', hidden: -> { true } + get '/simple/lazy' do + { foo: 'bar' } + end + end + + class HideNamespaceApi < Grape::API + mount HideNamespaceMountedApi + add_swagger_documentation openapi_version: '3.0' + end + end + + def app + HideNamespaceApi + end + + it 'retrieves swagger-documentation on /swagger_doc' do + get '/swagger_doc.json' + expect(JSON.parse(last_response.body)).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [{ 'name' => 'simple', 'description' => 'Operations about simples' }], + 'paths' => { + '/simple/show' => { + 'get' => { + 'description' => 'Show this endpoint', + 'operationId' => 'getSimpleShow', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'Show this endpoint' + } + }, + 'tags' => ['simple'] + } + } + } + ) + end + + it "retrieves the documentation for mounted-api that doesn't include hidden endpoints" do + get '/swagger_doc/simple.json' + expect(JSON.parse(last_response.body)).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [{ 'name' => 'simple', 'description' => 'Operations about simples' }], + 'paths' => { + '/simple/show' => { + 'get' => { + 'description' => 'Show this endpoint', + 'operationId' => 'getSimpleShow', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'Show this endpoint' + } + }, + 'tags' => ['simple'] + } + } + } + ) + end +end diff --git a/spec/openapi_3/host_spec.rb b/spec/openapi_3/host_spec.rb new file mode 100644 index 00000000..94e3dd25 --- /dev/null +++ b/spec/openapi_3/host_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'host in the swagger_doc' do + before :all do + module TheApi + class EmptyApi < Grape::API + format :json + + desc 'This gets something.' + get '/something' do + { bla: 'something' } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::EmptyApi + end + + describe 'host should include port' do + subject do + get 'http://example.com:8080/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject['servers'].first['url']).to eq 'http://example.com:8080' + end + end + + describe 'respect X-Forwarded-Host over Host header' do + subject do + header 'Host', 'dummy.example.com' + header 'X-Forwarded-Host', 'real.example.com' + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject['servers'].first['url']).to eq 'http://real.example.com' + end + end +end diff --git a/spec/openapi_3/mount_override_api_spec.rb b/spec/openapi_3/mount_override_api_spec.rb new file mode 100644 index 00000000..487999a9 --- /dev/null +++ b/spec/openapi_3/mount_override_api_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'mount override api' do + def app + old_api = Class.new(Grape::API) do + desc 'old endpoint', success: { code: 200, message: 'old message' } + params do + optional :param, type: Integer, desc: 'old param' + end + get do + 'old' + end + end + + new_api = Class.new(Grape::API) do + desc 'new endpoint', success: { code: 200, message: 'new message' } + params do + optional :param, type: String, desc: 'new param' + end + get do + 'new' + end + end + + Class.new(Grape::API) do + mount new_api + mount old_api + + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + context 'actual api request' do + subject do + get '/' + last_response.body + end + + it 'returns data from new endpoint' do + is_expected.to eq 'new' + end + end + + context 'api documentation' do + subject do + get '/swagger_doc' + JSON.parse(last_response.body)['paths']['/']['get'] + end + + it 'shows documentation from new endpoint' do + expect(subject['parameters'][0]['description']).to eql('new param') + expect(subject['parameters'][0]['schema']['type']).to eql('string') + expect(subject['responses']['200']['description']).to eql('new message') + end + end +end diff --git a/spec/openapi_3/mounted_target_class_spec.rb b/spec/openapi_3/mounted_target_class_spec.rb new file mode 100644 index 00000000..a548b720 --- /dev/null +++ b/spec/openapi_3/mounted_target_class_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +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, openapi_version: '3.0' + end + + class WholeApp < Grape::API + mount ActualApi + mount MountedDocs + end + end + + def app + WholeApp + end + + subject do + JSON.parse(last_response.body) + end + + it 'retrieves docs for actual api class' do + get '/swagger_doc.json' + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [{ 'name' => 'simple', 'description' => 'Operations about simples' }], + 'paths' => { + '/simple' => { + 'get' => { + 'description' => 'This gets something.', + 'operationId' => 'getSimple', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'This gets something.' + } + }, + 'tags' => ['simple'] + } + } + } + ) + end + + it 'retrieves docs for endpoint in actual api class' do + get '/swagger_doc/simple.json' + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + 'servers' => [{ 'url' => 'http://example.org' }], + 'tags' => [{ 'name' => 'simple', 'description' => 'Operations about simples' }], + 'paths' => { + '/simple' => { + 'get' => { + 'description' => 'This gets something.', + 'operationId' => 'getSimple', + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'This gets something.' + } + }, + 'tags' => ['simple'] + } + } + } + ) + end +end diff --git a/spec/openapi_3/namespace_tags_prefix_spec.rb b/spec/openapi_3/namespace_tags_prefix_spec.rb new file mode 100644 index 00000000..7ba6efd0 --- /dev/null +++ b/spec/openapi_3/namespace_tags_prefix_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'namespace tags check while using prefix and version' do + include_context 'namespace example' + + before :all do + module TheApi + class NamespaceApi < Grape::API + version %i[v1 v2] + end + + class CascadingVersionApi < Grape::API + version :v2 + + namespace :hudson do + desc 'Document root' + get '/' do + end + end + + namespace :colorado do + desc 'This gets something.', + notes: '_test_' + + get '/simple' do + { bla: 'something' } + end + end + end + end + + class TagApi < Grape::API + prefix :api + mount TheApi::CascadingVersionApi + mount TheApi::NamespaceApi + add_swagger_documentation openapi_version: '3.0' + end + end + + def app + TagApi + end + + describe 'retrieves swagger-documentation on /swagger_doc' do + subject do + get '/api/swagger_doc.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['tags']).to eql( + [ + { 'name' => 'hudson', 'description' => 'Operations about hudsons' }, + { 'name' => 'colorado', 'description' => 'Operations about colorados' }, + { 'name' => 'thames', 'description' => 'Operations about thames' }, + { 'name' => 'niles', 'description' => 'Operations about niles' } + ] + ) + + expect(subject['paths']['/api/v1/hudson']['get']['tags']).to eql(['hudson']) + expect(subject['paths']['/api/v1/colorado/simple']['get']['tags']).to eql(['colorado']) + expect(subject['paths']['/api/v1/colorado/simple-test']['get']['tags']).to eql(['colorado']) + expect(subject['paths']['/api/v1/thames/simple_with_headers']['get']['tags']).to eql(['thames']) + expect(subject['paths']['/api/v1/niles/items']['post']['tags']).to eql(['niles']) + expect(subject['paths']['/api/v1/niles/custom']['get']['tags']).to eql(['niles']) + expect(subject['paths']['/api/v2/hudson']['get']['tags']).to eql(['hudson']) + expect(subject['paths']['/api/v2/colorado/simple']['get']['tags']).to eql(['colorado']) + end + end + + describe 'retrieves the documentation for mounted-api' do + subject do + get '/api/swagger_doc/colorado.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['tags']).to eql( + [ + { 'name' => 'colorado', 'description' => 'Operations about colorados' } + ] + ) + + expect(subject['paths']['/api/v1/colorado/simple']['get']['tags']).to eql(['colorado']) + expect(subject['paths']['/api/v1/colorado/simple-test']['get']['tags']).to eql(['colorado']) + end + + describe 'includes headers' do + subject do + get '/api/swagger_doc/thames.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['tags']).to eql( + [ + { 'name' => 'thames', 'description' => 'Operations about thames' } + ] + ) + + expect(subject['paths']['/api/v1/thames/simple_with_headers']['get']['tags']).to eql(['thames']) + end + end + end +end diff --git a/spec/openapi_3/namespace_tags_spec.rb b/spec/openapi_3/namespace_tags_spec.rb new file mode 100644 index 00000000..a09ac0d2 --- /dev/null +++ b/spec/openapi_3/namespace_tags_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'namespace tags check' do + include_context 'namespace example' + + before :all do + module TheApi + class NamespaceApi2 < Grape::API + namespace :hudson do + desc 'Document root' + get '/' do + end + end + + namespace :colorado do + desc 'This gets something.', + notes: '_test_' + + get '/simple' do + { bla: 'something' } + end + end + + namespace :colorado do + desc 'This gets something for URL using - separator.', + notes: '_test_' + + get '/simple-test' do + { bla: 'something' } + end + end + + namespace :thames do + desc 'this gets something else', + headers: { + 'XAuthToken' => { description: 'A required header.', required: true }, + 'XOtherHeader' => { description: 'An optional header.', required: false } + }, + http_codes: [ + { code: 403, message: 'invalid pony' }, + { code: 405, message: 'no ponies left!' } + ] + + get '/simple_with_headers' do + { bla: 'something_else' } + end + end + + namespace :niles do + desc 'this takes an array of parameters', + params: { + 'items[]' => { description: 'array of items', is_array: true } + } + + post '/items' do + {} + end + end + + namespace :niles do + desc 'this uses a custom parameter', + params: { + 'custom' => { type: CustomType, description: 'array of items', is_array: true } + } + + get '/custom' do + {} + end + end + end + end + + class TestApi < Grape::API + mount TheApi::NamespaceApi2 + add_swagger_documentation openapi_version: '3.0' + end + end + + def app + TestApi + end + + describe 'retrieves swagger-documentation on /swagger_doc' do + subject do + get '/swagger_doc.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['tags']).to eql( + [ + { 'name' => 'hudson', 'description' => 'Operations about hudsons' }, + { 'name' => 'colorado', 'description' => 'Operations about colorados' }, + { 'name' => 'thames', 'description' => 'Operations about thames' }, + { 'name' => 'niles', 'description' => 'Operations about niles' } + ] + ) + + expect(subject['paths']['/hudson']['get']['tags']).to eql(['hudson']) + expect(subject['paths']['/colorado/simple']['get']['tags']).to eql(['colorado']) + expect(subject['paths']['/colorado/simple-test']['get']['tags']).to eql(['colorado']) + expect(subject['paths']['/thames/simple_with_headers']['get']['tags']).to eql(['thames']) + expect(subject['paths']['/niles/items']['post']['tags']).to eql(['niles']) + expect(subject['paths']['/niles/custom']['get']['tags']).to eql(['niles']) + end + end + + describe 'retrieves the documentation for mounted-api' do + subject do + get '/swagger_doc/colorado.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['tags']).to eql( + [ + { 'name' => 'colorado', 'description' => 'Operations about colorados' } + ] + ) + + expect(subject['paths']['/colorado/simple']['get']['tags']).to eql(['colorado']) + expect(subject['paths']['/colorado/simple-test']['get']['tags']).to eql(['colorado']) + end + + describe 'includes headers' do + subject do + get '/swagger_doc/thames.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['tags']).to eql( + [ + { 'name' => 'thames', 'description' => 'Operations about thames' } + ] + ) + + expect(subject['paths']['/thames/simple_with_headers']['get']['tags']).to eql(['thames']) + end + end + end +end diff --git a/spec/openapi_3/namespaced_api_spec.rb b/spec/openapi_3/namespaced_api_spec.rb new file mode 100644 index 00000000..9174cc4b --- /dev/null +++ b/spec/openapi_3/namespaced_api_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'namespace' do + context 'at root level' do + def app + Class.new(Grape::API) do + namespace :aspace do + get '/', desc: 'Description for aspace' + end + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body)['paths']['/aspace']['get'] + end + + it 'shows the namespace summary in the json spec' do + expect(subject['summary']).to eql('Description for aspace') + end + end + + context 'with camel case namespace' do + def app + Class.new(Grape::API) do + namespace :camelCases do + get '/', desc: 'Look! An endpoint.' + end + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body)['paths']['/camelCases']['get'] + end + + it 'shows the namespace summary in the json spec' do + expect(subject['summary']).to eql('Look! An endpoint.') + end + end + + context 'mounted' do + def app + namespaced_api = Class.new(Grape::API) do + namespace :bspace do + get '/', desc: 'Description for aspace' + end + end + + Class.new(Grape::API) do + mount namespaced_api + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body)['paths']['/bspace']['get'] + end + + it 'shows the namespace summary in the json spec' do + expect(subject['summary']).to eql('Description for aspace') + end + end + + context 'mounted under a route' do + def app + namespaced_api = Class.new(Grape::API) do + namespace :bspace do + get '/', desc: 'Description for aspace' + end + end + + Class.new(Grape::API) do + mount namespaced_api => '/mounted' + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body)['paths']['/mounted/bspace']['get'] + end + + it 'shows the namespace summary in the json spec' do + expect(subject['summary']).to eql('Description for aspace') + end + end + + context 'arbitrary mounting' do + def app + inner_namespaced_api = Class.new(Grape::API) do + namespace :bspace do + get '/', desc: 'Description for aspace' + end + end + + outer_namespaced_api = Class.new(Grape::API) do + mount inner_namespaced_api => '/mounted' + end + + Class.new(Grape::API) do + mount outer_namespaced_api => '/' + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body)['paths']['/mounted/bspace']['get'] + end + + it 'shows the namespace summary in the json spec' do + expect(subject['summary']).to eql('Description for aspace') + end + end +end diff --git a/spec/openapi_3/nicknamed_openapi_3_spec.rb b/spec/openapi_3/nicknamed_openapi_3_spec.rb new file mode 100644 index 00000000..4f4b667e --- /dev/null +++ b/spec/openapi_3/nicknamed_openapi_3_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'a nicknamed mounted api' do + def app + Class.new(Grape::API) do + desc 'Show this endpoint', nickname: 'simple' + get '/simple' do + { foo: 'bar' } + end + + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + subject do + get '/swagger_doc.json' + JSON.parse(last_response.body) + end + + it 'uses the nickname as the operationId' do + expect(subject['paths']['/simple']['get']['operationId']).to eql('simple') + end +end diff --git a/spec/openapi_3/openapi_3_body_definitions_spec.rb b/spec/openapi_3/openapi_3_body_definitions_spec.rb new file mode 100644 index 00000000..f0dd2eb5 --- /dev/null +++ b/spec/openapi_3/openapi_3_body_definitions_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'body parameter definitions' do + before :all do + module TheBodyApi + class Endpoint < Grape::API + resource :endpoint do + desc 'The endpoint' do + headers XAuthToken: { + description: 'Valdates your identity', + required: true + } + params body_param: { type: 'String', desc: 'param', documentation: { in: 'body' } }, + body_type_as_const_param: { type: String, desc: 'string_param', documentation: { in: 'body' } } + end + + post do + { 'declared_params' => declared(params) } + end + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheBodyApi::Endpoint + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + context 'a definition is generated for the endpoints parameters defined within the desc block' do + specify do + expect(subject['components']['schemas']['postEndpoint']['properties']).to eql( + 'body_param' => { 'type' => 'string', 'description' => 'param' }, + 'body_type_as_const_param' => { 'type' => 'string', 'description' => 'string_param' } + ) + + expect(subject['paths']['/endpoint']['post']['parameters'].any? { |p| p['name'] == 'XAuthToken' && p['in'] == 'header' }).to eql(true) + end + end +end diff --git a/spec/openapi_3/openapi_3_detail_spec.rb b/spec/openapi_3/openapi_3_detail_spec.rb new file mode 100644 index 00000000..bd411504 --- /dev/null +++ b/spec/openapi_3/openapi_3_detail_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +def details + <<-DETAILS + # Burgers in Heaven + + > A burger doesn't come for free + + If you want to reserve a burger in heaven, you have to do + some crazy stuff on earth. + + ``` + def do_good + puts 'help people' + end + ``` + + * _Will go to Heaven:_ Probably + * _Will go to Hell:_ Probably not + DETAILS +end + +describe 'details' do + describe 'take details as it is' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class DetailApi < Grape::API + format :json + + desc 'This returns something', + detail: 'detailed description of the route', + entity: Entities::UseResponse, + failure: [{ code: 400, model: Entities::ApiError }] + get '/use_detail' do + { 'declared_params' => declared(params) } + end + + desc 'This returns something' do + detail 'detailed description of the route inside the `desc` block' + entity Entities::UseResponse + failure [{ code: 400, model: Entities::ApiError }] + end + get '/use_detail_block' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::DetailApi + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/use_detail']['get']).to include('summary') + expect(subject['paths']['/use_detail']['get']['summary']).to eql 'This returns something' + expect(subject['paths']['/use_detail']['get']).to include('description') + expect(subject['paths']['/use_detail']['get']['description']).to eql 'detailed description of the route' + end + + specify do + expect(subject['paths']['/use_detail_block']['get']).to include('summary') + expect(subject['paths']['/use_detail_block']['get']['summary']).to eql 'This returns something' + expect(subject['paths']['/use_detail_block']['get']).to include('description') + expect(subject['paths']['/use_detail_block']['get']['description']).to eql 'detailed description of the route inside the `desc` block' + end + end +end diff --git a/spec/openapi_3/openapi_3_extensions_spec.rb b/spec/openapi_3/openapi_3_extensions_spec.rb new file mode 100644 index 00000000..e7e63c7f --- /dev/null +++ b/spec/openapi_3/openapi_3_extensions_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'extensions' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class ExtensionsApi < Grape::API + format :json + + route_setting :x_path, some: 'stuff' + + desc 'This returns something with extension on path level', + params: Entities::UseResponse.documentation, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + get '/path_extension' do + { 'declared_params' => declared(params) } + end + + route_setting :x_operation, some: 'stuff' + + desc 'This returns something with extension on verb level', + params: Entities::UseResponse.documentation, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + params do + requires :id, type: Integer + end + get '/verb_extension' do + { 'declared_params' => declared(params) } + end + + route_setting :x_def, for: 200, some: 'stuff' + + desc 'This returns something with extension on definition level', + params: Entities::ResponseItem.documentation, + success: Entities::ResponseItem, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + get '/definitions_extension' do + { 'declared_params' => declared(params) } + end + + route_setting :x_def, [{ for: 422, other: 'stuff' }, { for: 200, some: 'stuff' }] + + desc 'This returns something with extension on definition level', + success: Entities::OtherItem + get '/non_existent_status_definitions_extension' do + { 'declared_params' => declared(params) } + end + + route_setting :x_def, [{ for: 422, other: 'stuff' }, { for: 200, some: 'stuff' }] + + desc 'This returns something with extension on definition level', + success: Entities::OtherItem, + failure: [{ code: 422, message: 'NotFound', model: Entities::SecondApiError }] + get '/multiple_definitions_extension' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation(openapi_version: '3.0', x: { some: 'stuff' }) + end + end + end + + def app + TheApi::ExtensionsApi + end + + describe 'extension on root level' do + subject do + get '/swagger_doc/path_extension' + JSON.parse(last_response.body) + end + + specify do + expect(subject).to include 'x-some' + expect(subject['x-some']).to eql 'stuff' + end + end + + describe 'extension on path level' do + subject do + get '/swagger_doc/path_extension' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/path_extension']).to include 'x-some' + expect(subject['paths']['/path_extension']['x-some']).to eql 'stuff' + end + end + + describe 'extension on verb level' do + subject do + get '/swagger_doc/verb_extension' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/verb_extension']['get']).to include 'x-some' + expect(subject['paths']['/verb_extension']['get']['x-some']).to eql 'stuff' + end + end + + describe 'extension on definition level' do + subject do + get '/swagger_doc/definitions_extension' + JSON.parse(last_response.body) + end + + specify do + expect(subject['components']['schemas']['ResponseItem']).to include 'x-some' + expect(subject['components']['schemas']['ResponseItem']['x-some']).to eql 'stuff' + expect(subject['components']['schemas']['ApiError']).not_to include 'x-some' + end + end + + describe 'extension on definition level' do + subject do + get '/swagger_doc/multiple_definitions_extension' + JSON.parse(last_response.body) + end + + specify do + expect(subject['components']['schemas']['OtherItem']).to include 'x-some' + expect(subject['components']['schemas']['OtherItem']['x-some']).to eql 'stuff' + expect(subject['components']['schemas']['SecondApiError']).to include 'x-other' + expect(subject['components']['schemas']['SecondApiError']['x-other']).to eql 'stuff' + end + end + + describe 'extension on definition level' do + subject do + get '/swagger_doc/non_existent_status_definitions_extension' + JSON.parse(last_response.body) + end + + specify do + expect(subject['components']['schemas'].length).to eql 1 + expect(subject['components']['schemas']['OtherItem']).to include 'x-some' + expect(subject['components']['schemas']['OtherItem']['x-some']).to eql 'stuff' + end + end +end diff --git a/spec/openapi_3/openapi_3_format-content_type_spec.rb b/spec/openapi_3/openapi_3_format-content_type_spec.rb new file mode 100644 index 00000000..901e4d56 --- /dev/null +++ b/spec/openapi_3/openapi_3_format-content_type_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'format, content_type' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class ProducesApi < Grape::API + format :json + + desc 'This uses json (default) for produces', + failure: [{ code: 400, model: Entities::ApiError }], + entity: Entities::UseResponse + get '/use_default' do + { 'declared_params' => declared(params) } + end + + desc 'This uses formats for produces', + failure: [{ code: 400, model: Entities::ApiError }], + formats: [:xml, :binary, 'application/vdns'], + entity: Entities::UseResponse + get '/use_formats' do + { 'declared_params' => declared(params) } + end + + desc 'This uses content_types for produces', + failure: [{ code: 400, model: Entities::ApiError }], + content_types: [:xml, :binary, 'application/vdns'], + entity: Entities::UseResponse + get '/use_content_types' do + { 'declared_params' => declared(params) } + end + + desc 'This uses produces for produces', + failure: [{ code: 400, model: Entities::ApiError }], + produces: [:xml, :binary, 'application/vdns'], + entity: Entities::UseResponse + get '/use_produces' do + { 'declared_params' => declared(params) } + end + + desc 'This uses consumes for consumes', + failure: [{ code: 400, model: Entities::ApiError }], + consumes: ['application/www_url_encoded'], + entity: Entities::UseResponse + post '/use_consumes' do + { 'declared_params' => declared(params) } + end + + desc 'This uses consumes for consumes', + failure: [{ code: 400, model: Entities::ApiError }], + consumes: ['application/www_url_encoded'], + entity: Entities::UseResponse + patch '/use_consumes' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::ProducesApi + end + + let(:produced) do + %w[application/xml application/octet-stream application/vdns] + end + + def content(path, status_code) + subject['paths'][path]['get']['responses'][status_code.to_s]['content'] + end + + describe 'default' do + subject do + get '/swagger_doc/use_default' + JSON.parse(last_response.body) + end + + specify do + expect(content('/use_default', 200)).to include('application/json') + expect(content('/use_default', 400)).to include('application/json') + expect(content('/use_default', 200).keys.count).to eq 1 + expect(content('/use_default', 400).keys.count).to eq 1 + end + end + + describe 'formats' do + subject do + get '/swagger_doc/use_formats' + JSON.parse(last_response.body) + end + + specify do + expect(content('/use_formats', 200).keys).to eq produced + expect(content('/use_formats', 400).keys).to eq produced + end + end + + describe 'content types' do + subject do + get '/swagger_doc/use_content_types' + JSON.parse(last_response.body) + end + + specify do + expect(content('/use_content_types', 200).keys).to eq produced + expect(content('/use_content_types', 400).keys).to eq produced + end + end + + describe 'produces' do + subject do + get '/swagger_doc/use_produces' + JSON.parse(last_response.body) + end + + specify do + expect(content('/use_produces', 200).keys).to eq produced + expect(content('/use_produces', 400).keys).to eq produced + end + end + + describe 'consumes' do + subject do + get '/swagger_doc/use_consumes' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/use_consumes']['post']['requestBody']).to include('content') + expect(subject['paths']['/use_consumes']['post']['requestBody']['content'].keys).to eql ['application/www_url_encoded'] + expect(subject['paths']['/use_consumes']['patch']['requestBody']).to include('content') + expect(subject['paths']['/use_consumes']['patch']['requestBody']['content'].keys).to eql ['application/www_url_encoded'] + end + end +end diff --git a/spec/openapi_3/openapi_3_global_configuration_spec.rb b/spec/openapi_3/openapi_3_global_configuration_spec.rb new file mode 100644 index 00000000..7e9ae78a --- /dev/null +++ b/spec/openapi_3/openapi_3_global_configuration_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'global configuration stuff' do + before :all do + module TheApi + class ConfigurationApi < Grape::API + format :json + version 'v3', using: :path + + desc 'This returns something', + failure: [{ code: 400, message: 'NotFound' }] + params do + requires :foo, type: Integer + end + get :configuration do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0', + format: :json, + doc_version: '23', + schemes: 'https', + host: -> { 'another.host.com' }, + base_path: -> { '/somewhere/over/the/rainbow' }, + mount_path: 'documentation', + add_base_path: true, + add_version: true, + security_definitions: { foo: { type: 'apiKey', in: 'header', name: 'bar' } }, + security: [{ api_key: [] }] + end + end + end + + def app + TheApi::ConfigurationApi + end + + describe 'shows documentation paths' do + subject do + get '/v3/documentation' + JSON.parse(last_response.body) + end + + specify do + expect(subject['info']['version']).to eql '23' + expect(subject['servers'].first['url']).to eql 'http://another.host.com/somewhere/over/the/rainbow' + expect(subject['paths'].keys.first).to eql '/somewhere/over/the/rainbow/v3/configuration' + expect(subject['components']['securitySchemes'].keys).to include('foo') + expect(subject['components']['securitySchemes']['foo']).to include('name' => 'bar') + expect(subject['security']).to include('api_key' => []) + end + end +end diff --git a/spec/openapi_3/openapi_3_hash_and_array_spec.rb b/spec/openapi_3/openapi_3_hash_and_array_spec.rb new file mode 100644 index 00000000..3d173fa7 --- /dev/null +++ b/spec/openapi_3/openapi_3_hash_and_array_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'document hash and array' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class TestApi < Grape::API + format :json + + documentation = ::Entities::DocumentedHashAndArrayModel.documentation if ::Entities::DocumentedHashAndArrayModel.respond_to?(:documentation) + + desc 'This returns something' + namespace :arbitrary do + params do + requires :id, type: Integer + end + route_param :id do + desc 'Timeless treasure' + params do + requires :body, using: documentation unless documentation.nil? + requires :raw_hash, type: Hash, documentation: { param_type: 'body' } if documentation.nil? + requires :raw_array, type: Array, documentation: { param_type: 'body' } if documentation.nil? + end + put '/id_and_hash' do + {} + end + end + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::TestApi + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + describe 'generated request definition' do + it 'has hash' do + expect(subject['components']['schemas'].keys).to include('putArbitraryIdIdAndHash') + expect(subject['components']['schemas']['putArbitraryIdIdAndHash']['properties'].keys).to include('raw_hash') + end + + it 'has array' do + expect(subject['components']['schemas'].keys).to include('putArbitraryIdIdAndHash') + expect(subject['components']['schemas']['putArbitraryIdIdAndHash']['properties'].keys).to include('raw_array') + end + + it 'does not have the path parameter' do + expect(subject['components']['schemas'].keys).to include('putArbitraryIdIdAndHash') + expect(subject['components']['schemas']['putArbitraryIdIdAndHash']).to_not include('id') + end + end +end diff --git a/spec/openapi_3/openapi_3_headers_spec.rb b/spec/openapi_3/openapi_3_headers_spec.rb new file mode 100644 index 00000000..fc165e63 --- /dev/null +++ b/spec/openapi_3/openapi_3_headers_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'headers' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class HeadersApi < Grape::API + format :json + + desc 'This returns something', + failure: [{ code: 400, model: Entities::ApiError }], + headers: { + 'X-Rate-Limit-Limit' => { + 'description' => 'The number of allowed requests in the current period', + 'type' => 'integer' + } + }, + + entity: Entities::UseResponse + params do + optional :param_x, type: String, desc: 'This is a parameter', documentation: { param_type: 'query' } + end + get '/use_headers' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::HeadersApi + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + parameters = subject['paths']['/use_headers']['get']['parameters'] + expect(parameters).to include( + 'in' => 'header', + 'name' => 'X-Rate-Limit-Limit', + 'description' => 'The number of allowed requests in the current period', + 'schema' => { 'format' => 'int32', 'type' => 'integer' }, + 'required' => false + ) + expect(parameters.size).to eq(2) + expect(parameters.first['in']).to eq('header') + expect(parameters.last['in']).to eq('query') + end +end diff --git a/spec/openapi_3/openapi_3_hide_documentation_path_spec.rb b/spec/openapi_3/openapi_3_hide_documentation_path_spec.rb new file mode 100644 index 00000000..c396d388 --- /dev/null +++ b/spec/openapi_3/openapi_3_hide_documentation_path_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'hide documentation path' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class HideDocumentationApi < Grape::API + format :json + + desc 'This returns something', + params: Entities::UseResponse.documentation, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + params do + requires :foo, type: Integer + end + get '/params_response' do + { 'declared_params' => declared(params) } + end + + desc 'This returns something', + entity: Entities::UseResponse, + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + get '/entity_response' do + { 'declared_params' => declared(params) } + end + + desc 'This returns something', + failure: [{ code: 400, message: 'NotFound', model: Entities::ApiError }] + get '/present_response' do + foo = OpenStruct.new id: 1, name: 'bar' + something = OpenStruct.new description: 'something', item: foo + present :somethings, something, with: Entities::UseResponse + end + + add_swagger_documentation openapi_version: '3.0', hide_documentation_path: false + end + end + end + + def app + TheApi::HideDocumentationApi + end + + describe 'shows documentation paths' do + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths'].keys).to include '/swagger_doc', '/swagger_doc/{name}' + end + end +end diff --git a/spec/openapi_3/openapi_3_hide_param_spec.rb b/spec/openapi_3/openapi_3_hide_param_spec.rb new file mode 100644 index 00000000..54f7844c --- /dev/null +++ b/spec/openapi_3/openapi_3_hide_param_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'hidden flag enables a single endpoint parameter to be excluded from the documentation' do + include_context "#{MODEL_PARSER} swagger example" + before :all do + module TheApi + class HideParamsApi < Grape::API + namespace :flat_params_endpoint do + desc 'This is a endpoint with a flat parameter hierarchy' + params do + requires :name, type: String, documentation: { desc: 'name' } + optional :favourite_color, type: String, documentation: { desc: 'I should not be anywhere', hidden: true } + optional :proc_param, type: String, documentation: { desc: 'I should not be anywhere', hidden: -> { true } } + end + + post do + { 'declared_params' => declared(params) } + end + end + + namespace :nested_params_endpoint do + desc 'This is a endpoint with a nested parameter hierarchy' + params do + optional :name, type: String, documentation: { desc: 'name' } + optional :hidden_attribute, type: Hash do + optional :favourite_color, type: String, documentation: { desc: 'I should not be anywhere', hidden: true } + end + + optional :attributes, type: Hash do + optional :attribute_1, type: String, documentation: { desc: 'Attribute one' } + optional :hidden_attribute, type: String, documentation: { desc: 'I should not be anywhere', hidden: true } + end + end + + post do + { 'declared_params' => declared(params) } + end + end + + namespace :required_param_endpoint do + desc 'This endpoint has hidden defined for a required parameter' + params do + requires :name, type: String, documentation: { desc: 'name', hidden: true } + end + + post do + { 'declared_params' => declared(params) } + end + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + let(:app) { TheApi::HideParamsApi } + + def property_names(path) + subject['paths'][path]['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema']['properties'].keys.map { |p| p['name'] } + end + + describe 'simple flat parameter hierarchy' do + subject do + get '/swagger_doc/flat_params_endpoint' + JSON.parse(last_response.body) + end + + specify do + expect(property_names('/flat_params_endpoint')).not_to include('favourite_color', 'proc_param') + end + end + + describe 'nested parameter hierarchy' do + subject do + get '/swagger_doc/nested_params_endpoint' + JSON.parse(last_response.body) + end + + specify do + expect(property_names('/nested_params_endpoint')).not_to include(/hidden_attribute/) + end + end + + describe 'hidden defined for required parameter' do + subject do + get '/swagger_doc/required_param_endpoint' + JSON.parse(last_response.body) + end + + specify do + expect(property_names('/required_param_endpoint')).to include('name') + end + end +end diff --git a/spec/openapi_3/openapi_3_ignore_defaults_spec.rb b/spec/openapi_3/openapi_3_ignore_defaults_spec.rb new file mode 100644 index 00000000..79320f39 --- /dev/null +++ b/spec/openapi_3/openapi_3_ignore_defaults_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'swagger spec v2.0' do + include_context "#{MODEL_PARSER} swagger example" + + def app + Class.new(Grape::API) do + format :json + + desc 'This creates Thing after a delay', + success: { code: 202, message: 'OK', model: Entities::Something } + params do + requires :text, type: String, documentation: { type: 'string', desc: 'Content of something.' } + requires :links, type: Array, documentation: { type: 'link', is_array: true } + end + post '/delay_thing' do + status 202 + end + + version 'v3', using: :path + add_swagger_documentation openapi_version: '3.0', + api_version: 'v1', + base_path: '/api', + info: { + title: 'The API title to be displayed on the API homepage.', + description: 'A description of the API.', + contact_name: 'Contact name', + contact_email: 'Contact@email.com', + contact_url: 'www.The-Contact-URL.org', + license: 'The name of the license.', + license_url: 'www.The-URL-of-the-license.org', + terms_of_service_url: 'www.The-URL-of-the-terms-and-service.com' + } + end + end + + before do + get '/v3/swagger_doc' + end + + let(:json) { JSON.parse(last_response.body) } + + it 'only returns one response if ignore_defaults is specified' do + expect(json['paths']['/delay_thing']['post']['responses']).to eq('202' => { + 'content' => { + 'application/json' => { + 'schema' => { + '$ref' => '#/components/schemas/Something' + } + } + }, 'description' => 'OK' + }) + expect(json['paths']['/delay_thing']['post']['responses'].keys).not_to include '201' + end +end diff --git a/spec/openapi_3/openapi_3_param_type_body_nested_spec.rb b/spec/openapi_3/openapi_3_param_type_body_nested_spec.rb new file mode 100644 index 00000000..8d76465c --- /dev/null +++ b/spec/openapi_3/openapi_3_param_type_body_nested_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'moving body/formData Params to definitions' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class NestedBodyParamTypeApi < Grape::API + namespace :simple_nested_params do + desc 'post in body with nested parameters', + detail: 'more details description', + success: Entities::UseNestedWithAddress + params do + optional :contact, type: Hash do + requires :name, type: String, documentation: { desc: 'name', in: 'body' } + optional :addresses, type: Array do + requires :street, type: String, documentation: { desc: 'street', in: 'body' } + requires :postcode, type: String, documentation: { desc: 'postcode', in: 'body' } + requires :city, type: String, documentation: { desc: 'city', in: 'body' } + optional :country, type: String, documentation: { desc: 'country', in: 'body' } + end + end + end + + post '/in_body' do + { 'declared_params' => declared(params) } + end + + desc 'put in body with nested parameters', + detail: 'more details description', + success: Entities::UseNestedWithAddress + params do + requires :id, type: Integer + optional :name, type: String, documentation: { desc: 'name', in: 'body' } + optional :address, type: Hash do + optional :street, type: String, documentation: { desc: 'street', in: 'body' } + optional :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' } + optional :city, type: String, documentation: { desc: 'city', in: 'body' } + optional :country, type: String, documentation: { desc: 'country', in: 'body' } + end + end + + put '/in_body/:id' do + { 'declared_params' => declared(params) } + end + end + + namespace :multiple_nested_params do + desc 'put in body with multiple nested parameters', + success: Entities::UseNestedWithAddress + params do + optional :contact, type: Hash do + requires :name, type: String, documentation: { desc: 'name', in: 'body' } + optional :addresses, type: Array do + optional :street, type: String, documentation: { desc: 'street', in: 'body' } + requires :postcode, type: Integer, documentation: { desc: 'postcode', in: 'formData' } + optional :city, type: String, documentation: { desc: 'city', in: 'body' } + optional :country, type: String, documentation: { desc: 'country', in: 'body' } + end + optional :delivery_address, type: Hash do + optional :street, type: String, documentation: { desc: 'street', in: 'body' } + optional :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' } + optional :city, type: String, documentation: { desc: 'city', in: 'body' } + optional :country, type: String, documentation: { desc: 'country', in: 'body' } + end + end + end + + post '/in_body' do + { 'declared_params' => declared(params) } + end + + desc 'put in body with multiple nested parameters', + success: Entities::UseNestedWithAddress + params do + requires :id, type: Integer + optional :name, type: String, documentation: { desc: 'name', in: 'body' } + optional :address, type: Hash do + optional :street, type: String, documentation: { desc: 'street', in: 'body' } + requires :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' } + optional :city, type: String, documentation: { desc: 'city', in: 'body' } + optional :country, type: String, documentation: { desc: 'country', in: 'body' } + end + optional :delivery_address, type: Hash do + optional :street, type: String, documentation: { desc: 'street', in: 'body' } + optional :postcode, type: String, documentation: { desc: 'postcode', in: 'formData' } + optional :city, type: String, documentation: { desc: 'city', in: 'body' } + optional :country, type: String, documentation: { desc: 'country', in: 'body' } + end + end + + put '/in_body/:id' do + { 'declared_params' => declared(params) } + end + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::NestedBodyParamTypeApi + end + + describe 'nested body parameters given' do + subject do + get '/swagger_doc/simple_nested_params' + JSON.parse(last_response.body) + end + + describe 'POST' do + let(:endpoint) { subject['paths']['/simple_nested_params/in_body']['post'] } + + specify do + expect(endpoint['requestBody']['content']['application/json']).to eql( + 'schema' => { + 'properties' => { + 'SimpleNestedParamsInBody' => { + '$ref' => '#/components/schemas/postSimpleNestedParamsInBody' + } + }, + 'required' => ['SimpleNestedParamsInBody'], 'type' => 'object' + } + ) + end + + specify do + expect(subject['components']['schemas']['postSimpleNestedParamsInBody']).to eql( + 'type' => 'object', + 'properties' => { + 'contact' => { + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string', 'description' => 'name' }, + 'addresses' => { + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'street' => { 'type' => 'string', 'description' => 'street' }, + 'postcode' => { 'type' => 'string', 'description' => 'postcode' }, + 'city' => { 'type' => 'string', 'description' => 'city' }, + 'country' => { 'type' => 'string', 'description' => 'country' } + }, + 'required' => %w[street postcode city] + } + } + }, + 'required' => %w[name] + } + }, + 'description' => 'post in body with nested parameters' + ) + end + end + + describe 'PUT' do + let(:endpoint) { subject['paths']['/simple_nested_params/in_body/{id}']['put'] } + + specify do + expect(endpoint['parameters']).to eql( + [{ + 'in' => 'path', 'name' => 'id', 'schema' => { 'format' => 'int32', 'type' => 'integer' }, 'required' => true + }] + ) + + expect(endpoint['requestBody']['content']['application/json']).to eql( + 'schema' => { + 'properties' => { + 'SimpleNestedParamsInBody' => { + '$ref' => '#/components/schemas/putSimpleNestedParamsInBody' + } + }, 'required' => ['SimpleNestedParamsInBody'], 'type' => 'object' + } + ) + end + + specify do + expect(subject['components']['schemas']['putSimpleNestedParamsInBody']).to eql( + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string', 'description' => 'name' }, + 'address' => { + 'type' => 'object', + 'properties' => { + 'street' => { 'type' => 'string', 'description' => 'street' }, + 'postcode' => { 'type' => 'string', 'description' => 'postcode' }, + 'city' => { 'type' => 'string', 'description' => 'city' }, + 'country' => { 'type' => 'string', 'description' => 'country' } + } + } + }, + 'description' => 'put in body with nested parameters' + ) + end + end + end + + describe 'multiple nested body parameters given' do + subject do + get '/swagger_doc/multiple_nested_params' + JSON.parse(last_response.body) + end + + describe 'POST' do + let(:endpoint) { subject['paths']['/multiple_nested_params/in_body']['post'] } + + specify do + expect(endpoint['requestBody']['content']['application/json']).to eql( + 'schema' => { + 'properties' => { + 'MultipleNestedParamsInBody' => { + '$ref' => '#/components/schemas/postMultipleNestedParamsInBody' + } + }, + 'required' => ['MultipleNestedParamsInBody'], + 'type' => 'object' + } + ) + end + + specify do + expect(subject['components']['schemas']['postMultipleNestedParamsInBody']).to eql( + 'type' => 'object', + 'properties' => { + 'contact' => { + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string', 'description' => 'name' }, + 'addresses' => { + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'street' => { 'type' => 'string', 'description' => 'street' }, + 'postcode' => { 'type' => 'integer', 'format' => 'int32', 'description' => 'postcode' }, + 'city' => { 'type' => 'string', 'description' => 'city' }, + 'country' => { 'type' => 'string', 'description' => 'country' } + }, + 'required' => ['postcode'] + } + }, + 'delivery_address' => { + 'type' => 'object', + 'properties' => { + 'street' => { 'type' => 'string', 'description' => 'street' }, + 'postcode' => { 'type' => 'string', 'description' => 'postcode' }, + 'city' => { 'type' => 'string', 'description' => 'city' }, + 'country' => { 'type' => 'string', 'description' => 'country' } + } + } + }, + 'required' => %w[name] + } + }, + 'description' => 'put in body with multiple nested parameters' + ) + end + end + + describe 'PUT' do + let(:endpoint) { subject['paths']['/multiple_nested_params/in_body/{id}']['put'] } + + specify do + expect(endpoint['parameters']).to eql( + [{ + 'in' => 'path', + 'name' => 'id', + 'schema' => { 'format' => 'int32', 'type' => 'integer' }, + 'required' => true + }] + ) + expect(endpoint['requestBody']['content']['application/json']).to eql( + 'schema' => { + 'properties' => { + 'MultipleNestedParamsInBody' => { + '$ref' => '#/components/schemas/putMultipleNestedParamsInBody' + } + }, + 'required' => ['MultipleNestedParamsInBody'], + 'type' => 'object' + } + ) + end + + specify do + expect(subject['components']['schemas']['putMultipleNestedParamsInBody']).to eql( + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string', 'description' => 'name' }, + 'address' => { + 'type' => 'object', + 'properties' => { + 'street' => { 'type' => 'string', 'description' => 'street' }, + 'postcode' => { 'type' => 'string', 'description' => 'postcode' }, + 'city' => { 'type' => 'string', 'description' => 'city' }, + 'country' => { 'type' => 'string', 'description' => 'country' } + }, + 'required' => ['postcode'] + }, + 'delivery_address' => { + 'type' => 'object', + 'properties' => { + 'street' => { 'type' => 'string', 'description' => 'street' }, + 'postcode' => { 'type' => 'string', 'description' => 'postcode' }, + 'city' => { 'type' => 'string', 'description' => 'city' }, + 'country' => { 'type' => 'string', 'description' => 'country' } + } + } + }, + 'description' => 'put in body with multiple nested parameters' + ) + end + end + end +end diff --git a/spec/openapi_3/openapi_3_param_type_body_spec.rb b/spec/openapi_3/openapi_3_param_type_body_spec.rb new file mode 100644 index 00000000..dbfa89c2 --- /dev/null +++ b/spec/openapi_3/openapi_3_param_type_body_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'setting of param type, such as `query`, `path`, `formData`, `body`, `header`' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class BodyParamTypeApi < Grape::API + namespace :wo_entities do + desc 'post in body /wo entity' + params do + requires :in_body_1, type: Integer, documentation: { desc: 'in_body_1', param_type: 'body' } + optional :in_body_2, type: String, documentation: { desc: 'in_body_2', param_type: 'body' } + optional :in_body_3, type: String, documentation: { desc: 'in_body_3', param_type: 'body' } + end + + post '/in_body' do + { 'declared_params' => declared(params) } + end + + desc 'put in body /wo entity' + params do + requires :key, type: Integer + optional :in_body_1, type: Integer, documentation: { desc: 'in_body_1', param_type: 'body' } + optional :in_body_2, type: String, documentation: { desc: 'in_body_2', param_type: 'body' } + optional :in_body_3, type: String, documentation: { desc: 'in_body_3', param_type: 'body' } + end + + put '/in_body/:key' do + { 'declared_params' => declared(params) } + end + end + + namespace :with_entities do + desc 'post in body with entity', + success: ::Entities::ResponseItem + params do + requires :name, type: String, documentation: { desc: 'name', param_type: 'body' } + end + + post '/in_body' do + { 'declared_params' => declared(params) } + end + + desc 'put in body with entity', + success: ::Entities::ResponseItem + params do + requires :id, type: Integer + optional :name, type: String, documentation: { desc: 'name', param_type: 'body' } + end + + put '/in_body/:id' do + { 'declared_params' => declared(params) } + end + end + + namespace :with_entity_param do + desc 'put in body with entity parameter' + params do + optional :data, type: ::Entities::NestedModule::ApiResponse, documentation: { desc: 'request data' } + end + + post do + { 'declared_params' => declared(params) } + end + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::BodyParamTypeApi + end + + describe 'no entity given' do + subject do + get '/swagger_doc/wo_entities' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/wo_entities/in_body']['post']['requestBody']['content']['application/json']).to eql( + 'schema' => { + '$ref' => '#/components/schemas/postWoEntitiesInBody' + } + ) + end + + specify do + expect(subject['components']['schemas']['postWoEntitiesInBody']).to eql( + 'description' => 'post in body /wo entity', + 'type' => 'object', + 'properties' => { + 'in_body_1' => { 'type' => 'integer', 'format' => 'int32', 'description' => 'in_body_1' }, + 'in_body_2' => { 'type' => 'string', 'description' => 'in_body_2' }, + 'in_body_3' => { 'type' => 'string', 'description' => 'in_body_3' } + }, + 'required' => ['in_body_1'] + ) + end + + specify do + expect(subject['paths']['/wo_entities/in_body/{key}']['put']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'key', 'schema' => { 'format' => 'int32', 'type' => 'integer' }, 'required' => true } + ] + ) + + expect(subject['paths']['/wo_entities/in_body/{key}']['put']['requestBody']['content']['application/json']).to eql( + 'schema' => { + '$ref' => '#/components/schemas/putWoEntitiesInBody' + } + ) + end + + specify do + expect(subject['components']['schemas']['putWoEntitiesInBody']).to eql( + 'description' => 'put in body /wo entity', + 'type' => 'object', + 'properties' => { + 'in_body_1' => { 'type' => 'integer', 'format' => 'int32', 'description' => 'in_body_1' }, + 'in_body_2' => { 'type' => 'string', 'description' => 'in_body_2' }, + 'in_body_3' => { 'type' => 'string', 'description' => 'in_body_3' } + } + ) + end + end + + describe 'entity given' do + subject do + get '/swagger_doc/with_entities' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/with_entities/in_body']['post']['requestBody']['content']['application/json']).to eql( + 'schema' => { + '$ref' => '#/components/schemas/postWithEntitiesInBody' + } + ) + end + + specify do + expect(subject['components']['schemas']['postWithEntitiesInBody']).to eql( + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string', 'description' => 'name' } + }, + 'required' => ['name'], + 'description' => 'post in body with entity' + ) + end + + specify do + expect(subject['paths']['/with_entities/in_body/{id}']['put']['parameters']).to eql( + [ + { + 'in' => 'path', + 'name' => 'id', + 'schema' => { 'format' => 'int32', 'type' => 'integer' }, + 'required' => true + } + ] + ) + + expect(subject['paths']['/with_entities/in_body/{id}']['put']['requestBody']['content']['application/json']).to eql( + 'schema' => { + '$ref' => '#/components/schemas/putWithEntitiesInBody' + } + ) + end + + specify do + expect(subject['components']['schemas']['putWithEntitiesInBody']).to eql( + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string', 'description' => 'name' } + }, + 'description' => 'put in body with entity' + ) + end + end + + describe 'complex entity given' do + let(:request_parameters_definition) do + { + 'schema' => { + '$ref' => '#/components/schemas/postWithEntityParam' + } + } + end + + let(:request_body_parameters_definition) do + { + 'description' => 'put in body with entity parameter', + 'properties' => { 'data' => { '$ref' => '#/components/schemas/NestedModule_ApiResponse', 'description' => 'request data' } }, + 'type' => 'object' + } + end + + subject do + get '/swagger_doc/with_entity_param' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/with_entity_param']['post']['requestBody']['content']['application/json']).to eql(request_parameters_definition) + end + + specify do + expect(subject['components']['schemas']['NestedModule_ApiResponse']).not_to be_nil + end + + specify do + expect(subject['components']['schemas']['postWithEntityParam']).to eql(request_body_parameters_definition) + end + end +end diff --git a/spec/openapi_3/openapi_3_request_params_fix_spec.rb b/spec/openapi_3/openapi_3_request_params_fix_spec.rb new file mode 100644 index 00000000..b144bb08 --- /dev/null +++ b/spec/openapi_3/openapi_3_request_params_fix_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'additional parameter settings' do + before :all do + module TheApi + class RequestParamFix < Grape::API + resource :bookings do + desc 'Update booking' + params do + optional :name, type: String + end + put ':id' do + { 'declared_params' => declared(params) } + end + + desc 'Get booking details' + get ':id' do + { 'declared_params' => declared(params) } + end + + desc 'Get booking details by access_number' + get '/conf/:access_number' do + { 'declared_params' => declared(params) } + end + + desc 'Remove booking' + delete ':id' do + { 'declared_params' => declared(params) } + end + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::RequestParamFix + end + + subject do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/bookings/{id}']['put']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'id', 'schema' => { 'format' => 'int32', 'type' => 'integer' }, 'required' => true } + ] + ) + + expect(subject['paths']['/bookings/{id}']['put']['requestBody']).to eql('content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { 'name' => { 'type' => 'string' } }, + 'type' => 'object' + } + } + }) + end + + specify do + expect(subject['paths']['/bookings/{id}']['get']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'id', 'schema' => { 'format' => 'int32', 'type' => 'integer' }, 'required' => true } + ] + ) + end + + specify do + expect(subject['paths']['/bookings/{id}']['delete']['parameters']).to eql( + [ + { 'in' => 'path', 'name' => 'id', 'schema' => { 'format' => 'int32', 'type' => 'integer' }, 'required' => true } + ] + ) + end +end diff --git a/spec/openapi_3/openapi_3_response_with_examples_spec.rb b/spec/openapi_3/openapi_3_response_with_examples_spec.rb new file mode 100644 index 00000000..c1adc0f4 --- /dev/null +++ b/spec/openapi_3/openapi_3_response_with_examples_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'response with examples' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class ResponseApiExamples < Grape::API + format :json + + desc 'This returns examples' do + success model: Entities::UseResponse, examples: { 'application/json' => { description: 'Names list', items: [{ id: '123', name: 'John' }] } } + failure [[404, 'NotFound', Entities::ApiError, { 'application/json' => { code: 404, message: 'Not found' } }]] + end + get '/response_examples' do + { 'declared_params' => declared(params) } + end + + desc 'This returns multiple examples' do + success model: Entities::UseResponse, examples: { 'foo' => { description: 'Names list', items: [{ id: '123', name: 'John' }] }, 'bar' => { description: 'Another list', items: [{ id: '123', something: 'John' }] } } + failure [[404, 'NotFound', Entities::ApiError, { 'application/json' => { code: 404, message: 'Not found' } }]] + end + get '/response_multiple_examples' do + { 'declared_params' => declared(params) } + end + + desc 'This syntax also returns examples' do + success model: Entities::UseResponse, examples: { 'application/json' => { description: 'Names list', items: [{ id: '123', name: 'John' }] } } + failure [ + { + code: 404, + message: 'NotFound', + model: Entities::ApiError, + examples: { 'application/json' => { code: 404, message: 'Not found' } } + }, + { + code: 400, + message: 'BadRequest', + model: Entities::ApiError, + examples: { 'application/json' => { code: 400, message: 'Bad Request' } } + } + ] + end + get '/response_failure_examples' do + { 'declared_params' => declared(params) } + end + + desc 'This does not return examples' do + success model: Entities::UseResponse + failure [[404, 'NotFound', Entities::ApiError]] + end + get '/response_no_examples' do + { 'declared_params' => declared(params) } + end + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::ResponseApiExamples + end + + describe 'response examples' do + let(:example_200) { { 'description' => 'Names list', 'items' => [{ 'id' => '123', 'name' => 'John' }] } } + let(:example_404) { { 'code' => 404, 'message' => 'Not found' } } + + subject do + get '/swagger_doc/response_examples' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/response_examples']['get']).to eql( + 'description' => 'This returns examples', + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { + 'example' => example_200, + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + }, + 'description' => 'This returns examples' + }, + '404' => { + 'content' => { + 'application/json' => { + 'example' => example_404, + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'NotFound' + } + }, + 'tags' => ['response_examples'], + 'operationId' => 'getResponseExamples' + ) + end + end + + describe 'response multiple examples' do + let(:example_200) do + { + 'bar' => { 'value' => { 'description' => 'Another list', 'items' => [{ 'id' => '123', 'something' => 'John' }] } }, + 'foo' => { 'value' => { 'description' => 'Names list', 'items' => [{ 'id' => '123', 'name' => 'John' }] } } + } + end + let(:example_404) { { 'code' => 404, 'message' => 'Not found' } } + + subject do + get '/swagger_doc/response_multiple_examples' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/response_multiple_examples']['get']).to eql( + 'description' => 'This returns multiple examples', + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { + 'examples' => example_200, + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + }, + 'description' => 'This returns multiple examples' + }, + '404' => { + 'content' => { + 'application/json' => { + 'example' => example_404, + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'NotFound' + } + }, + 'tags' => ['response_multiple_examples'], + 'operationId' => 'getResponseMultipleExamples' + ) + end + end + + describe 'response failure examples' do + let(:example_200) do + { 'application/json' => { 'description' => 'Names list', 'items' => [{ 'id' => '123', 'name' => 'John' }] } } + end + let(:example_404) do + { 'application/json' => { 'code' => 404, 'message' => 'Not found' } } + end + let(:example_400) do + { 'application/json' => { 'code' => 400, 'message' => 'Bad Request' } } + end + + subject do + get '/swagger_doc/response_failure_examples' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/response_failure_examples']['get']).to eql( + 'description' => 'This syntax also returns examples', + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { + 'example' => { 'description' => 'Names list', 'items' => [{ 'id' => '123', 'name' => 'John' }] }, + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + }, + 'description' => 'This syntax also returns examples' + }, + '400' => { + 'content' => { + 'application/json' => { + 'example' => { 'code' => 400, 'message' => 'Bad Request' }, + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'BadRequest' + }, + '404' => { + 'content' => { + 'application/json' => { + 'example' => { 'code' => 404, 'message' => 'Not found' }, + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'NotFound' + } + }, + 'tags' => ['response_failure_examples'], + 'operationId' => 'getResponseFailureExamples' + ) + end + end + + describe 'response no examples' do + subject do + get '/swagger_doc/response_no_examples' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/response_no_examples']['get']).to eql( + 'description' => 'This does not return examples', + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/UseResponse' } } + }, + 'description' => 'This does not return examples' + }, + '404' => { + 'content' => { + 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/ApiError' } } + }, + 'description' => 'NotFound' + } + }, + 'tags' => ['response_no_examples'], + 'operationId' => 'getResponseNoExamples' + ) + end + end +end diff --git a/spec/openapi_3/openapi_3_response_with_headers_spec.rb b/spec/openapi_3/openapi_3_response_with_headers_spec.rb new file mode 100644 index 00000000..4e180ebf --- /dev/null +++ b/spec/openapi_3/openapi_3_response_with_headers_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'response with headers' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class ResponseApiHeaders < Grape::API + format :json + + desc 'This returns headers' do + success model: Entities::UseResponse, headers: { 'Location' => { description: 'Location of resource', type: 'string' } } + failure [[404, 'NotFound', Entities::ApiError, { 'application/json' => { code: 404, message: 'Not found' } }, { 'Date' => { description: 'Date of failure', type: 'string' } }]] + end + get '/response_headers' do + { 'declared_params' => declared(params) } + end + + desc 'A 204 can have headers too' do + success Hash[status: 204, message: 'No content', headers: { 'Location' => { description: 'Location of resource', type: 'string' } }] + failure [[400, 'Bad Request', Entities::ApiError, { 'application/json' => { code: 400, message: 'Bad request' } }, { 'Date' => { description: 'Date of failure', type: 'string' } }]] + end + delete '/no_content_response_headers' do + { 'declared_params' => declared(params) } + end + + desc 'A file can have headers too' do + success Hash[status: 200, model: 'File', headers: { 'Cache-Control' => { description: 'Directive for caching', type: 'string' } }] + failure [[404, 'NotFound', Entities::ApiError, { 'application/json' => { code: 404, message: 'Not found' } }, { 'Date' => { description: 'Date of failure', type: 'string' } }]] + end + get '/file_response_headers' do + { 'declared_params' => declared(params) } + end + + desc 'This syntax also returns headers' do + success model: Entities::UseResponse, headers: { 'Location' => { description: 'Location of resource', type: 'string' } } + failure [ + { + code: 404, + message: 'NotFound', + model: Entities::ApiError, + headers: { 'Date' => { description: 'Date of failure', type: 'string' } } + }, + { + code: 400, + message: 'BadRequest', + model: Entities::ApiError, + headers: { 'Date' => { description: 'Date of failure', type: 'string' } } + } + ] + end + get '/response_failure_headers' do + { 'declared_params' => declared(params) } + end + + desc 'This does not return headers' do + success model: Entities::UseResponse + failure [[404, 'NotFound', Entities::ApiError]] + end + get '/response_no_headers' do + { 'declared_params' => declared(params) } + end + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::ResponseApiHeaders + end + + describe 'response headers' do + let(:header_200) do + { 'Location' => { 'description' => 'Location of resource', 'type' => 'string' } } + end + let(:header_404) do + { 'Date' => { 'description' => 'Date of failure', 'type' => 'string' } } + end + let(:examples_404) do + { 'application/json' => { 'code' => 404, 'message' => 'Not found' } } + end + + subject do + get '/swagger_doc/response_headers' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/response_headers']['get']).to eql( + 'description' => 'This returns headers', + 'operationId' => 'getResponseHeaders', + 'tags' => ['response_headers'], + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + }, + 'description' => 'This returns headers', + 'headers' => { + 'Location' => { + 'description' => 'Location of resource', + 'schema' => { 'type' => 'string' } + } + } + }, + '404' => { + 'content' => { + 'application/json' => { + 'example' => { + 'code' => 404, + 'message' => 'Not found' + }, + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'NotFound', + 'headers' => { + 'Date' => { + 'description' => 'Date of failure', + 'schema' => { 'type' => 'string' } + } + } + } + } + ) + end + end + + describe 'no content response headers' do + let(:header_204) do + { 'Location' => { 'description' => 'Location of resource', 'type' => 'string' } } + end + let(:header_400) do + { 'Date' => { 'description' => 'Date of failure', 'type' => 'string' } } + end + let(:examples_400) do + { 'application/json' => { 'code' => 400, 'message' => 'Bad request' } } + end + + subject do + get '/swagger_doc/no_content_response_headers', {} + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/no_content_response_headers']['delete']).to eql( + 'description' => 'A 204 can have headers too', + 'responses' => { + '204' => { + 'description' => 'No content', + 'headers' => { + 'Location' => { + 'description' => 'Location of resource', 'schema' => { 'type' => 'string' } + } + } + }, + '400' => { + 'content' => { + 'application/json' => { + 'example' => { 'code' => 400, 'message' => 'Bad request' }, + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'Bad Request', + 'headers' => { 'Date' => { 'description' => 'Date of failure', 'schema' => { 'type' => 'string' } } } + } + }, + 'tags' => ['no_content_response_headers'], + 'operationId' => 'deleteNoContentResponseHeaders' + ) + end + end + + describe 'file response headers' do + let(:header_200) do + { 'Cache-Control' => { 'description' => 'Directive for caching', 'type' => 'string' } } + end + let(:header_404) do + { 'Date' => { 'description' => 'Date of failure', 'type' => 'string' } } + end + let(:examples_404) do + { 'application/json' => { 'code' => 404, 'message' => 'Not found' } } + end + + subject do + get '/swagger_doc/file_response_headers' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/file_response_headers']['get']).to eql( + 'description' => 'A file can have headers too', + 'responses' => { + '200' => { + 'content' => { + 'application/octet-stream' => { 'schema' => {} } + }, + 'description' => 'A file can have headers too', + 'headers' => { + 'Cache-Control' => { 'description' => 'Directive for caching', 'schema' => { 'type' => 'string' } } + } + }, + '404' => { + 'content' => { + 'application/json' => { + 'example' => { 'code' => 404, 'message' => 'Not found' }, + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'NotFound', + 'headers' => { + 'Date' => { 'description' => 'Date of failure', 'schema' => { 'type' => 'string' } } + } + } + }, + 'tags' => ['file_response_headers'], + 'operationId' => 'getFileResponseHeaders' + ) + end + end + + describe 'response failure headers' do + let(:header_200) do + { 'Location' => { 'description' => 'Location of resource', 'schema' => { 'type' => 'string' } } } + end + let(:header_404) do + { 'Date' => { 'description' => 'Date of failure', 'schema' => { 'type' => 'string' } } } + end + let(:header_400) do + { 'Date' => { 'description' => 'Date of failure', 'schema' => { 'type' => 'string' } } } + end + + subject do + get '/swagger_doc/response_failure_headers' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/response_failure_headers']['get']).to eql( + 'description' => 'This syntax also returns headers', + 'operationId' => 'getResponseFailureHeaders', + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + }, + 'description' => 'This syntax also returns headers', + 'headers' => header_200 + }, + '400' => { + 'content' => { + 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/ApiError' } } + }, + 'description' => 'BadRequest', + 'headers' => header_400 + }, + '404' => { + 'content' => { + 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/ApiError' } } + }, + 'description' => 'NotFound', + 'headers' => header_404 + } + }, + 'tags' => ['response_failure_headers'] + ) + end + end + + describe 'response no headers' do + subject do + get '/swagger_doc/response_no_headers' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/response_no_headers']['get']).to eql( + 'description' => 'This does not return headers', + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } + } + }, + 'description' => 'This does not return headers' + }, + '404' => { + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/ApiError' } + } + }, + 'description' => 'NotFound' + } + }, + 'tags' => ['response_no_headers'], + 'operationId' => 'getResponseNoHeaders' + ) + end + end +end diff --git a/spec/openapi_3/openapi_3_spec.rb b/spec/openapi_3/openapi_3_spec.rb new file mode 100644 index 00000000..5cc1c09e --- /dev/null +++ b/spec/openapi_3/openapi_3_spec.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'swagger spec v2.0' do + include_context "#{MODEL_PARSER} swagger example" + + def app + Class.new(Grape::API) do + format :json + + # Thing stuff + desc 'This gets Things.' do + params Entities::Something.documentation + http_codes [{ code: 401, message: 'Unauthorized', model: Entities::ApiError }] + end + get '/thing' do + something = OpenStruct.new text: 'something' + present something, with: Entities::Something + end + + desc 'This gets Things.' do + http_codes [ + { code: 200, message: 'get Horses', model: Entities::Something }, + { code: 401, message: 'HorsesOutError', model: Entities::ApiError } + ] + end + get '/thing2' do + something = OpenStruct.new text: 'something' + present something, with: Entities::Something + end + + desc 'This gets Thing.' do + http_codes [{ code: 200, message: 'getting a single thing' }, { code: 401, message: 'Unauthorized' }] + end + params do + requires :id, type: Integer + end + get '/thing/:id' do + something = OpenStruct.new text: 'something' + present something, with: Entities::Something + end + + desc 'This creates Thing.', + success: Entities::Something + params do + requires :text, type: String, documentation: { type: 'string', desc: 'Content of something.' } + requires :links, type: Array, documentation: { type: 'link', is_array: true } + end + post '/thing', http_codes: [{ code: 422, message: 'Unprocessible Entity' }] do + something = OpenStruct.new text: 'something' + present something, with: Entities::Something + end + + desc 'This updates Thing.', + success: Entities::Something + params do + requires :id, type: Integer + optional :text, type: String, desc: 'Content of something.' + optional :links, type: Array, documentation: { type: 'link', is_array: true } + end + put '/thing/:id' do + something = OpenStruct.new text: 'something' + present something, with: Entities::Something + end + + desc 'This deletes Thing.', + entity: Entities::Something + params do + requires :id, type: Integer + end + delete '/thing/:id' do + something = OpenStruct.new text: 'something' + present something, with: Entities::Something + end + + desc 'dummy route.', + failure: [{ code: 401, message: 'Unauthorized' }] + params do + requires :id, type: Integer + end + delete '/dummy/:id' do + end + + namespace :other_thing do + desc 'nested route inside namespace', + entity: Entities::QueryInput, + x: { + 'amazon-apigateway-auth' => { type: 'none' }, + 'amazon-apigateway-integration' => { type: 'aws', uri: 'foo_bar_uri', httpMethod: 'get' } + } + + params do + requires :elements, documentation: { + type: 'QueryInputElement', + desc: 'Set of configuration', + param_type: 'body', + is_array: true, + required: true + } + end + get '/:elements' do + present something, with: Entities::QueryInput + end + end + + version 'v3', using: :path + add_swagger_documentation openapi_version: '3.0', + api_version: 'v1', + base_path: '/api', + info: { + title: 'The API title to be displayed on the API homepage.', + description: 'A description of the API.', + contact_name: 'Contact name', + contact_email: 'Contact@email.com', + contact_url: 'www.The-Contact-URL.org', + license: 'The name of the license.', + license_url: 'www.The-URL-of-the-license.org', + terms_of_service_url: 'www.The-URL-of-the-terms-and-service.com' + } + end + end + + describe 'whole documentation' do + subject do + get '/v3/swagger_doc' + JSON.parse(last_response.body) + end + + describe 'swagger object' do + describe 'required keys' do + it { expect(subject.keys).to include 'openapi' } + it { expect(subject['openapi']).to eql '3.0.0' } + it { expect(subject.keys).to include 'info' } + it { expect(subject['info']).to be_a Hash } + it { expect(subject.keys).to include 'paths' } + it { expect(subject['paths']).to be_a Hash } + end + + describe 'info object required keys' do + let(:info) { subject['info'] } + + it { expect(info.keys).to include 'title' } + it { expect(info['title']).to be_a String } + it { expect(info.keys).to include 'version' } + it { expect(info['version']).to be_a String } + + describe 'license object' do + let(:license) { subject['info']['license'] } + + it { expect(license.keys).to include 'name' } + it { expect(license['name']).to be_a String } + it { expect(license.keys).to include 'url' } + it { expect(license['url']).to be_a String } + end + + describe 'contact object' do + let(:contact) { subject['info']['contact'] } + + it { expect(contact.keys).to include 'name' } + it { expect(contact['name']).to be_a String } + it { expect(contact.keys).to include 'email' } + it { expect(contact['email']).to be_a String } + it { expect(contact.keys).to include 'url' } + it { expect(contact['url']).to be_a String } + end + + describe 'global tags' do + let(:tags) { subject['tags'] } + + it { expect(tags).to be_a Array } + it { expect(tags).not_to be_empty } + end + end + + describe 'path object' do + let(:paths) { subject['paths'] } + + it 'hides documentation paths per default' do + expect(paths.keys).not_to include '/swagger_doc', '/swagger_doc/{name}' + end + + specify do + paths.each_pair do |path, value| + expect(path).to start_with('/') + expect(value).to be_a Hash + expect(value).not_to be_empty + + value.each do |method, declaration| + expect(http_verbs).to include method + expect(declaration).to have_key('responses') + + declaration['responses'].each do |status_code, response| + expect(status_code).to match(/\d{3}/) + expect(response).to have_key('description') + end + end + end + end + end + + describe 'definitions object' do + let(:definitions) { subject['components']['schemas'] } + + specify do + definitions.each do |model, properties| + expect(model).to match(/\w+/) + expect(properties).to have_key('properties') + end + end + end + end + + describe 'swagger file' do + it do + # TODO: '/v3/other_thing/{elements}' path does not have a path parameter. Not sure on how to handle that. + expect(subject).to eql openapi_json + end + end + end + + describe 'specific resource documentation' do + subject do + get '/v3/swagger_doc/other_thing' + JSON.parse(last_response.body) + end + + let(:tags) { subject['tags'] } + specify do + expect(tags).to eql [ + { + 'name' => 'other_thing', + 'description' => 'Operations about other_things' + } + ] + end + end +end diff --git a/spec/openapi_3/openapi_3_type-format_spec.rb b/spec/openapi_3/openapi_3_type-format_spec.rb new file mode 100644 index 00000000..47b74f0d --- /dev/null +++ b/spec/openapi_3/openapi_3_type-format_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# mapping of parameter types +# Grape -> Swagger (OpenApi) +# (type format) +# --------------------------------------------------- +# Integer -> integer int32 +# Numeric -> integer int64 +# Float -> number float +# BigDecimal -> number double +# String -> string +# Symbol -> string +# Date -> string date +# DateTime -> string date-time +# Time -> string date-time +# 'password' -> string password +# 'email' -> string email +# Boolean -> boolean +# JSON -> json +# Rack::Multipart::UploadedFile -> file + +describe 'type format settings' do + include_context "#{MODEL_PARSER} swagger example" + + before :all do + module TheApi + class TypeFormatApi < Grape::API + desc 'full set of request data types', + success: Entities::TypedDefinition + + params do + # grape supported data types + requires :param_integer, type: Integer + requires :param_long, type: Numeric + requires :param_float, type: Float + requires :param_double, type: BigDecimal + optional :param_string, type: String + optional :param_symbol, type: Symbol + requires :param_date, type: Date + requires :param_date_time, type: DateTime + requires :param_time, type: Time + optional :param_boolean, type: Boolean + optional :param_file, type: File + optional :param_json, type: JSON + end + + post '/request_types' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + end + + def app + TheApi::TypeFormatApi + end + + subject do + get '/swagger_doc/request_types' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/request_types']['post']['requestBody']).to eql( + 'content' => { + 'application/json' => { + 'schema' => { + 'properties' => { + 'param_json' => { 'type' => 'object' } + }, + 'type' => 'object' + } + }, + 'application/octet-stream' => { + 'schema' => { + 'properties' => { + 'param_file' => { + 'format' => 'binary', 'type' => 'string' + } + }, + 'type' => 'object' + } + }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'param_boolean' => { 'type' => 'boolean' }, + 'param_date' => { 'format' => 'date', 'type' => 'string' }, + 'param_date_time' => { 'format' => 'date-time', 'type' => 'string' }, + 'param_double' => { 'format' => 'double', 'type' => 'number' }, + 'param_float' => { 'format' => 'float', 'type' => 'number' }, + 'param_integer' => { 'format' => 'int32', 'type' => 'integer' }, + 'param_long' => { 'format' => 'int64', 'type' => 'integer' }, + 'param_string' => { 'type' => 'string' }, + 'param_symbol' => { 'type' => 'string' }, + 'param_time' => { 'format' => 'date-time', 'type' => 'string' } + }, + 'required' => %w[param_integer param_long param_float param_double param_date param_date_time param_time], + 'type' => 'object' + } + } + } + ) + end + + specify do + expect(subject['components']['schemas']['TypedDefinition']['properties']).to eql(swagger_typed_defintion) + end +end diff --git a/spec/openapi_3/operation_id_api_spec.rb b/spec/openapi_3/operation_id_api_spec.rb new file mode 100644 index 00000000..0036245b --- /dev/null +++ b/spec/openapi_3/operation_id_api_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'an operation id api' do + def app + Class.new(Grape::API) do + version '0.1' + + desc 'Show this endpoint' + get '/simple_opp' do + { foo: 'bar' } + end + + add_swagger_documentation openapi_version: '3.0', format: :json + end + end + + subject do + get '/0.1/swagger_doc.json' + JSON.parse(last_response.body) + end + + it 'uses build name as operationId' do + expect(subject['paths']['/0.1/simple_opp']['get']['operationId']).to eql('get01SimpleOpp') + end +end diff --git a/spec/openapi_3/param_multi_type_spec.rb b/spec/openapi_3/param_multi_type_spec.rb new file mode 100644 index 00000000..39ff8cf1 --- /dev/null +++ b/spec/openapi_3/param_multi_type_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Params Multi Types' do + def app + Class.new(Grape::API) do + format :json + + params do + if Grape::VERSION < '0.14' + requires :input, type: [String, Integer] + else + requires :input, types: [String, Integer] + end + requires :another_input, type: [String, Integer] + end + post :action do + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc/action' + expect(last_response.status).to eq 200 + body = JSON.parse last_response.body + body['paths']['/action']['post'] + end + + it 'reads request body type correctly' do + expect(subject['requestBody']['content']).to eq( + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { 'another_input' => { 'type' => 'string' }, 'input' => { 'type' => 'string' } }, + 'required' => %w[input another_input], + 'type' => 'object' + } + } + ) + end + + describe 'header params' do + def app + Class.new(Grape::API) do + format :json + + desc 'Some API', headers: { 'My-Header' => { required: true, description: 'Set this!' } } + params do + if Grape::VERSION < '0.14' + requires :input, type: [String, Integer] + else + requires :input, types: [String, Integer] + end + requires :another_input, type: [String, Integer] + end + post :action do + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + it 'reads parameter type correctly' do + expect(subject['parameters']).to eq([{ + 'description' => 'Set this!', + 'in' => 'header', + 'name' => 'My-Header', + 'required' => true, + 'schema' => { 'type' => 'string' } + }]) + end + + it 'has consistent types' do + request_body_types = subject['requestBody']['content']['application/x-www-form-urlencoded']['schema']['properties'].values.map { |param| param['type'] } + expect(request_body_types).to eq(%w[string string]) + + request_body_types = subject['parameters'].map { |param| param['schema']['type'] } + expect(request_body_types).to eq(%w[string]) + end + end +end diff --git a/spec/openapi_3/param_type_spec.rb b/spec/openapi_3/param_type_spec.rb new file mode 100644 index 00000000..308e1a53 --- /dev/null +++ b/spec/openapi_3/param_type_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Params Types' do + def app + Class.new(Grape::API) do + format :json + + params do + requires :input, type: String + end + post :action do + end + + params do + requires :input, type: String, default: '14', documentation: { type: 'email', default: '42' } + end + post :action_with_doc do + end + + add_swagger_documentation openapi_version: '3.0' + end + end + context 'with no documentation hash' do + subject do + get '/swagger_doc/action' + expect(last_response.status).to eq 200 + body = JSON.parse last_response.body + body['paths']['/action']['post'] + end + + it 'reads param type correctly' do + expect(subject['requestBody']).to eq 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'input' => { 'type' => 'string' } + }, + 'required' => ['input'], + 'type' => 'object' + } + } + } + end + + describe 'header params' do + def app + Class.new(Grape::API) do + format :json + + desc 'Some API', headers: { 'My-Header' => { required: true, description: 'Set this!' } } + params do + requires :input, type: String + end + post :action do + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + it 'has consistent types' do + parameter_type = subject['parameters'].map { |param| param['schema']['type'] } + expect(parameter_type).to eq(%w[string]) + + header_type = subject['requestBody']['content']['application/x-www-form-urlencoded']['schema']['properties'].values.map { |param| param['type'] } + expect(header_type).to eq(%w[string]) + end + end + end + + context 'with documentation hash' do + subject do + get '/swagger_doc/action_with_doc' + expect(last_response.status).to eq 200 + body = JSON.parse last_response.body + body['paths']['/action_with_doc']['post']['requestBody'] + end + + it 'reads param type correctly' do + expect(subject).to eq 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'input' => { 'default' => '42', 'format' => 'email', 'type' => 'string' } + }, + 'required' => ['input'], 'type' => 'object' + } + } + } + end + end +end diff --git a/spec/openapi_3/param_values_spec.rb b/spec/openapi_3/param_values_spec.rb new file mode 100644 index 00000000..7228ae5a --- /dev/null +++ b/spec/openapi_3/param_values_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'spec_helper' +# require 'grape_version' + +describe 'Convert values to enum or Range' do + def app + Class.new(Grape::API) do + format :json + + params do + requires :letter, type: String, values: %w[a b c] + end + post :plain_array do + 'ok' + end + + params do + requires :letter, type: String, values: proc { %w[d e f] } + end + post :array_in_proc do + 'ok' + end + + params do + requires :letter, type: String, values: 'a'..'z' + end + post :range_letter do + 'ok' + end + + params do + requires :integer, type: Integer, values: -5..5 + end + post :range_integer do + 'ok' + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + def first_parameter_info(request) + get "/swagger_doc/#{request}" + expect(last_response.status).to eq 200 + body = JSON.parse last_response.body + body['components']['schemas']["post#{request.camelize}"] + end + + context 'Plain array values' do + subject(:plain_array) { first_parameter_info('plain_array') } + it 'has values as array in enum' do + expect(plain_array).to eq( + 'properties' => { + 'letter' => { + 'enum' => %w[a b c], + 'type' => 'string' + } + }, + 'required' => ['letter'], + 'type' => 'object' + ) + end + end + + context 'Array in proc values' do + subject(:array_in_proc) { first_parameter_info('array_in_proc') } + + it 'has proc returned values as array in enum' do + expect(array_in_proc).to eq( + 'properties' => { + 'letter' => { + 'enum' => %w[d e f], + 'type' => 'string' + } + }, 'required' => ['letter'], + 'type' => 'object' + ) + end + end + + context 'Range values' do + subject(:range_letter) { first_parameter_info('range_letter') } + + it 'has letter range values' do + expect(range_letter).to eq( + 'properties' => { + 'letter' => { 'enum' => %w[a b c d e f g h i j k l m n o p q r s t u v w x y z], 'type' => 'string' } + }, + 'required' => ['letter'], + 'type' => 'object' + ) + end + + subject(:range_integer) { first_parameter_info('range_integer') } + + it 'has integer range values' do + expect(range_integer).to eq( + 'properties' => { + 'integer' => { + 'format' => 'int32', + 'maximum' => 5, + 'minimum' => -5, + 'type' => 'integer' + } + }, + 'required' => ['integer'], + 'type' => 'object' + ) + end + end +end + +describe 'Convert values to enum for float range and not arrays inside a proc', if: GrapeVersion.satisfy?('>= 0.11.0') do + def app + Class.new(Grape::API) do + format :json + + params do + requires :letter, type: String, values: proc { 'string' } + end + post :non_array_in_proc do + 'ok' + end + + params do + requires :float, type: Float, values: -5.0..5.0 + end + post :range_float do + 'ok' + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + def first_parameter_info(request) + get "/swagger_doc/#{request}" + expect(last_response.status).to eq 200 + body = JSON.parse last_response.body + body['components']['schemas']["post#{request.camelize}"] + end + + context 'Non array in proc values' do + subject(:non_array_in_proc) { first_parameter_info('non_array_in_proc') } + + it 'has proc returned value as string in enum' do + expect(non_array_in_proc).to eq( + 'properties' => { + 'letter' => { + 'enum' => ['string'], + 'type' => 'string' + } + }, + 'required' => ['letter'], + 'type' => 'object' + ) + end + end + + context 'Range values' do + subject(:range_float) { first_parameter_info('range_float') } + + it 'has float range values as string' do + expect(range_float).to eq( + 'properties' => { + 'float' => { + 'format' => 'float', 'maximum' => 5.0, 'minimum' => -5.0, 'type' => 'number' + } + }, + 'required' => ['float'], + 'type' => 'object' + ) + end + end +end diff --git a/spec/openapi_3/params_array_collection_format_spec.rb b/spec/openapi_3/params_array_collection_format_spec.rb new file mode 100644 index 00000000..607b7bfe --- /dev/null +++ b/spec/openapi_3/params_array_collection_format_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Group Array Params, using collection format' do + def app + Class.new(Grape::API) do + format :json + + params do + optional :array_of_strings, type: Array[String], documentation: { desc: 'array in csv collection format', param_type: 'query' } + end + + get '/array_of_strings_without_collection_format' do + { 'declared_params' => declared(params) } + end + + params do + optional :array_of_strings, type: Array[String], documentation: { collectionFormat: 'multi', desc: 'array in multi collection format', param_type: 'query' } + end + + get '/array_of_strings_multi_collection_format' do + { 'declared_params' => declared(params) } + end + + params do + optional :array_of_strings, type: Array[String], documentation: { collectionFormat: 'foo', param_type: 'query' } + end + + get '/array_of_strings_invalid_collection_format' do + { 'declared_params' => declared(params) } + end + + params do + optional :array_of_strings, type: Array[String], desc: 'array in brackets collection format', documentation: { collectionFormat: 'brackets', param_type: 'query' } + end + + get '/array_of_strings_brackets_collection_format' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + describe 'documentation for array parameter in default csv collectionFormat' do + subject do + get '/swagger_doc/array_of_strings_without_collection_format' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/array_of_strings_without_collection_format']['get']['parameters']).to eql( + [ + { 'in' => 'formData', 'name' => 'array_of_strings', 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => false, 'description' => 'array in csv collection format' } + ] + ) + end + end + + describe 'documentation for array parameters in multi collectionFormat set from documentation' do + subject do + get '/swagger_doc/array_of_strings_multi_collection_format' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/array_of_strings_multi_collection_format']['get']['parameters']).to eql( + [ + { 'in' => 'formData', 'name' => 'array_of_strings', 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => false, 'collectionFormat' => 'multi', 'description' => 'array in multi collection format' } + ] + ) + end + end + + describe 'documentation for array parameters in brackets collectionFormat set from documentation' do + subject do + get '/swagger_doc/array_of_strings_brackets_collection_format' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/array_of_strings_brackets_collection_format']['post']['requestBody']['content']['application/x-www-form-urlencoded']).to eql( + [ + { 'in' => 'formData', 'name' => 'array_of_strings', 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => false, 'collectionFormat' => 'brackets', 'description' => 'array in brackets collection format' } + ] + ) + end + end + + describe 'documentation for array parameters with collectionFormat set to invalid option' do + subject do + get '/swagger_doc/array_of_strings_invalid_collection_format' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/array_of_strings_invalid_collection_format']['post']['requestBody']['content']['application/x-www-form-urlencoded']).to eql( + 'schema' => { + 'properties' => { + 'array_of_strings' => { 'items' => { 'type' => 'string' }, 'type' => 'array' } + }, + 'type' => 'object' + } + ) + end + end +end diff --git a/spec/openapi_3/params_array_spec.rb b/spec/openapi_3/params_array_spec.rb new file mode 100644 index 00000000..7cf1c5fc --- /dev/null +++ b/spec/openapi_3/params_array_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Group Params as Array' do + include_context "#{MODEL_PARSER} swagger example" + + [true, false].each do |array_use_braces| + context "when array_use_braces option is set to #{array_use_braces}" do + let(:braces) { array_use_braces ? '[]' : '' } + + let(:app) do + Class.new(Grape::API) do + format :json + + params do + requires :required_group, type: Array do + requires :required_param_1 + requires :required_param_2 + end + end + post '/groups' do + { 'declared_params' => declared(params) } + end + + params do + requires :typed_group, type: Array do + requires :id, type: Integer, desc: 'integer given' + requires :name, type: String, desc: 'string given' + optional :email, type: String, desc: 'email given' + optional :others, type: Integer, values: [1, 2, 3] + end + end + post '/type_given' do + { 'declared_params' => declared(params) } + end + + # as body parameters it would be interpreted a bit different, + # cause it could not be distinguished anymore, so this would be translated to one array, + # see also next example for the difference + params do + requires :array_of_string, type: Array[String], documentation: { param_type: 'body', desc: 'nested array of strings' } + requires :array_of_integer, type: Array[Integer], documentation: { param_type: 'body', desc: 'nested array of integers' } + end + + post '/array_of_type' do + { 'declared_params' => declared(params) } + end + + params do + requires :array_of_string, type: Array[String], documentation: { param_type: 'body', desc: 'array of strings' } + requires :integer_value, type: Integer, documentation: { param_type: 'body', desc: 'integer value' } + end + + post '/object_and_array' do + { 'declared_params' => declared(params) } + end + + params do + requires :array_of_string, type: Array[String] + requires :array_of_integer, type: Array[Integer] + end + + post '/array_of_type_in_form' do + { 'declared_params' => declared(params) } + end + + params do + requires :array_of_entities, type: Array[Entities::ApiError] + end + + post '/array_of_entities' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0', array_use_braces: array_use_braces + end + end + + describe 'retrieves the documentation for grouped parameters' do + subject do + get '/swagger_doc/groups' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/groups']['post']['requestBody']['content']['application/json']).to eql( + 'schema' => { + 'properties' => { + "required_group#{braces}[required_param_1]" => { 'items' => { 'type' => 'string' }, 'type' => 'array' }, + "required_group#{braces}[required_param_2]" => { 'items' => { 'type' => 'string' }, 'type' => 'array' } + }, + 'required' => %W[required_group#{braces}[required_param_1] required_group#{braces}[required_param_2]], + 'type' => 'object' + } + ) + end + end + + describe 'retrieves the documentation for typed group parameters' do + subject do + get '/swagger_doc/type_given' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/type_given']['post']['requestBody']['content']['application/x-www-form-urlencoded']).to eql( + 'schema' => { + 'properties' => { + "typed_group#{braces}[email]" => { + 'description' => 'email given', + 'items' => { 'type' => 'string' }, + 'type' => 'array' + }, + "typed_group#{braces}[id]" => { + 'description' => 'integer given', + 'format' => 'int32', + 'items' => { 'type' => 'integer' }, + 'type' => 'array' + }, + "typed_group#{braces}[name]" => { + 'description' => 'string given', + 'items' => { 'type' => 'string' }, + 'type' => 'array' + }, + "typed_group#{braces}[others]" => { + 'format' => 'int32', + 'items' => { 'enum' => [1, 2, 3], 'type' => 'integer' }, + 'type' => 'array' + } + }, + 'required' => %W[typed_group#{braces}[id] typed_group#{braces}[name]], 'type' => 'object' + } + ) + end + end + + describe 'retrieves the documentation for parameters that are arrays of primitive types' do + subject do + get '/swagger_doc/array_of_type' + JSON.parse(last_response.body) + end + + specify do + expect(subject['components']['schemas']['postArrayOfType']['type']).to eql 'array' + expect(subject['components']['schemas']['postArrayOfType']['items']).to eql( + 'type' => 'object', + 'properties' => { + 'array_of_string' => { + 'type' => 'string', 'description' => 'nested array of strings' + }, + 'array_of_integer' => { + 'type' => 'integer', 'format' => 'int32', 'description' => 'nested array of integers' + } + }, + 'required' => %w[array_of_string array_of_integer] + ) + end + end + + describe 'documentation for simple and array parameters' do + subject do + get '/swagger_doc/object_and_array' + JSON.parse(last_response.body) + end + + specify do + expect(subject['components']['schemas']['postObjectAndArray']['type']).to eql 'object' + expect(subject['components']['schemas']['postObjectAndArray']['properties']).to eql( + 'array_of_string' => { + 'type' => 'array', + 'description' => 'array of strings', + 'items' => { + 'type' => 'string' + } + }, + 'integer_value' => { + 'type' => 'integer', 'format' => 'int32', 'description' => 'integer value' + } + ) + end + end + + describe 'retrieves the documentation for typed group parameters' do + subject do + get '/swagger_doc/array_of_type_in_form' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/array_of_type_in_form']['post']['requestBody']['content']['application/x-www-form-urlencoded']).to eql( + 'schema' => { + 'properties' => { + "array_of_integer#{braces}" => { 'format' => 'int32', 'items' => { 'type' => 'integer' }, 'type' => 'array' }, + "array_of_string#{braces}" => { 'items' => { 'type' => 'string' }, 'type' => 'array' } + }, + 'required' => %W[array_of_string#{braces} array_of_integer#{braces}], + 'type' => 'object' + } + ) + end + end + + describe 'documentation for entity array parameters' do + let(:parameters) do + { + 'properties' => { + "array_of_entities#{braces}" => { + 'items' => { '$ref' => '#/components/schemas/ApiError' }, + 'type' => 'array' + } + }, + 'required' => ["array_of_entities#{braces}"], 'type' => 'object' + } + end + + subject do + get '/swagger_doc/array_of_entities' + JSON.parse(last_response.body) + end + + specify do + expect(subject['components']['schemas']['ApiError']).not_to be_blank + expect(subject['paths']['/array_of_entities']['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema']).to eql(parameters) + end + end + end + end +end diff --git a/spec/openapi_3/params_hash_spec.rb b/spec/openapi_3/params_hash_spec.rb new file mode 100644 index 00000000..ac68494d --- /dev/null +++ b/spec/openapi_3/params_hash_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Group Params as Hash' do + def app + Class.new(Grape::API) do + format :json + + params do + requires :required_group, type: Hash do + requires :required_param_1 + requires :required_param_2 + end + end + post '/use_groups' do + { 'declared_params' => declared(params) } + end + + params do + requires :typed_group, type: Hash do + requires :id, type: Integer, desc: 'integer given' + requires :name, type: String, desc: 'string given' + optional :email, type: String, desc: 'email given' + optional :others, type: Integer, values: [1, 2, 3] + end + end + post '/use_given_type' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + describe 'grouped parameters' do + subject do + get '/swagger_doc/use_groups' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/use_groups']['post']).to include('requestBody') + expect(subject['paths']['/use_groups']['post']['requestBody']['content']).to eql( + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'required_group[required_param_1]' => { 'type' => 'string' }, + 'required_group[required_param_2]' => { 'type' => 'string' } + }, + 'required' => %w(required_group[required_param_1] required_group[required_param_2]), + 'type' => 'object' + } + } + ) + end + end + + describe 'grouped parameters with given type' do + subject do + get '/swagger_doc/use_given_type' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/use_given_type']['post']).to include('requestBody') + expect(subject['paths']['/use_given_type']['post']['requestBody']['content']).to eql( + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'typed_group[email]' => { 'description' => 'email given', 'type' => 'string' }, + 'typed_group[id]' => { 'description' => 'integer given', 'format' => 'int32', 'type' => 'integer' }, + 'typed_group[name]' => { 'description' => 'string given', 'type' => 'string' }, + 'typed_group[others]' => { 'enum' => [1, 2, 3], 'format' => 'int32', 'type' => 'integer' } + }, + 'required' => ['typed_group[id]', 'typed_group[name]'], + 'type' => 'object' + } + } + ) + end + end +end diff --git a/spec/openapi_3/params_nested_spec.rb b/spec/openapi_3/params_nested_spec.rb new file mode 100644 index 00000000..2b1e85f0 --- /dev/null +++ b/spec/openapi_3/params_nested_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'nested group params' do + [true, false].each do |array_use_braces| + context "when array_use_braces option is set to #{array_use_braces}" do + let(:braces) { array_use_braces ? '[]' : '' } + let(:app) do + Class.new(Grape::API) do + format :json + + params do + requires :a_array, type: Array do + requires :param_1, type: Integer + requires :b_array, type: Array do + requires :param_2, type: String + end + requires :c_hash, type: Hash do + requires :param_3, type: String + end + end + requires :a_array_foo, type: String + end + post '/nested_array' do + { 'declared_params' => declared(params) } + end + + params do + requires :a_hash, type: Hash do + requires :param_1, type: Integer + requires :b_hash, type: Hash do + requires :param_2, type: String + end + requires :c_array, type: Array do + requires :param_3, type: String + end + end + requires :a_hash_foo, type: String + end + post '/nested_hash' do + { 'declared_params' => declared(params) } + end + + add_swagger_documentation openapi_version: '3.0', array_use_braces: array_use_braces + end + end + + describe 'retrieves the documentation for nested array parameters' do + subject do + get '/swagger_doc/nested_array' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/nested_array']['post']['requestBody']['content']['application/x-www-form-urlencoded']).to eql( + 'schema' => { + 'properties' => { + "a_array#{braces}[b_array]#{braces}[param_2]" => { 'items' => { 'type' => 'string' }, 'type' => 'array' }, + "a_array#{braces}[c_hash][param_3]" => { 'items' => { 'type' => 'string' }, 'type' => 'array' }, + "a_array#{braces}[param_1]" => { 'format' => 'int32', 'items' => { 'type' => 'integer' }, 'type' => 'array' }, + 'a_array_foo' => { 'type' => 'string' } + }, + 'required' => ["a_array#{braces}[param_1]", + "a_array#{braces}[b_array]#{braces}[param_2]", + "a_array#{braces}[c_hash][param_3]", + 'a_array_foo'], + 'type' => 'object' + } + ) + end + end + + describe 'retrieves the documentation for nested hash parameters' do + subject do + get '/swagger_doc/nested_hash' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/nested_hash']['post']['requestBody']['content']['application/x-www-form-urlencoded']).to eql( + 'schema' => { + 'properties' => { + 'a_hash[b_hash][param_2]' => { 'type' => 'string' }, + "a_hash[c_array]#{braces}[param_3]" => { 'items' => { 'type' => 'string' }, 'type' => 'array' }, + 'a_hash[param_1]' => { 'format' => 'int32', 'type' => 'integer' }, + 'a_hash_foo' => { 'type' => 'string' } + }, + 'required' => ['a_hash[param_1]', + 'a_hash[b_hash][param_2]', + "a_hash[c_array]#{braces}[param_3]", + 'a_hash_foo'], + 'type' => 'object' + } + ) + end + end + end + end +end diff --git a/spec/openapi_3/security_requirement_spec.rb b/spec/openapi_3/security_requirement_spec.rb new file mode 100644 index 00000000..671fe389 --- /dev/null +++ b/spec/openapi_3/security_requirement_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'security requirement on endpoint method' do + def app + Class.new(Grape::API) do + desc 'Endpoint with security requirement', security: [oauth_pets: %w[read:pets write:pets]] + get '/with_security' do + { foo: 'bar' } + end + + add_swagger_documentation openapi_version: '3.0' + end + end + + subject do + get '/swagger_doc.json' + JSON.parse(last_response.body) + end + + it 'defines the security requirement on the endpoint method' do + expect(subject['paths']['/with_security']['get']['security']).to eql ['oauth_pets' => %w[read:pets write:pets]] + end +end diff --git a/spec/openapi_3/simple_mounted_api_spec.rb b/spec/openapi_3/simple_mounted_api_spec.rb new file mode 100644 index 00000000..b7b0fb20 --- /dev/null +++ b/spec/openapi_3/simple_mounted_api_spec.rb @@ -0,0 +1,393 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'a simple mounted api' do + before :all do + class CustomType; end + + class SimpleMountedApi < Grape::API + desc 'Document root' + get do + end + + desc 'This gets something.', + notes: '_test_' + + get '/simple' do + { bla: 'something' } + end + + desc 'This gets something for URL using - separator.', + notes: '_test_' + + get '/simple-test' do + { bla: 'something' } + end + + head '/simple-head-test' do + status 200 + end + + options '/simple-options-test' do + status 200 + end + + desc 'this gets something else', + headers: { + 'XAuthToken' => { description: 'A required header.', required: true }, + 'XOtherHeader' => { description: 'An optional header.', required: false } + }, + http_codes: [ + { code: 403, message: 'invalid pony' }, + { code: 405, message: 'no ponies left!' } + ] + + get '/simple_with_headers' do + { bla: 'something_else' } + end + + desc 'this takes an array of parameters', + params: { + 'items[]' => { description: 'array of items', is_array: true } + } + + post '/items' do + {} + end + + desc 'this uses a custom parameter', + params: { + 'custom' => { type: CustomType, description: 'array of items', is_array: true } + } + + post '/custom' do + {} + end + end + + class SimpleApi < Grape::API + mount SimpleMountedApi + add_swagger_documentation openapi_version: '3.0', servers: { + url: 'http://example.org' + } + end + end + + def app + SimpleApi + end + + describe 'retrieves swagger-documentation on /swagger_doc' do + subject do + get '/swagger_doc.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject).to eq( + 'info' => { + 'title' => 'API title', 'version' => '0.0.1' + }, + 'openapi' => '3.0.0', + 'servers' => [ + 'url' => 'http://example.org' + ], + 'tags' => [ + { 'name' => 'simple', 'description' => 'Operations about simples' }, + { 'name' => 'simple-test', 'description' => 'Operations about simple-tests' }, + { 'name' => 'simple-head-test', 'description' => 'Operations about simple-head-tests' }, + { 'name' => 'simple-options-test', 'description' => 'Operations about simple-options-tests' }, + { 'name' => 'simple_with_headers', 'description' => 'Operations about simple_with_headers' }, + { 'name' => 'items', 'description' => 'Operations about items' }, + { 'name' => 'custom', 'description' => 'Operations about customs' } + ], + 'paths' => { + '/' => { + 'get' => { + 'description' => 'Document root', + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'Document root' } }, + 'operationId' => 'get' + } + }, + '/simple' => { + 'get' => { + 'description' => 'This gets something.', + 'tags' => ['simple'], + 'operationId' => 'getSimple', + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something.' } } + } + }, + '/simple-test' => { + 'get' => { + 'description' => 'This gets something for URL using - separator.', + 'tags' => ['simple-test'], + 'operationId' => 'getSimpleTest', + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something for URL using - separator.' } } + } + }, + '/simple-head-test' => { + 'head' => { + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'head SimpleHeadTest' } }, + 'tags' => ['simple-head-test'], + 'operationId' => 'headSimpleHeadTest' + } + }, + '/simple-options-test' => { + 'options' => { + 'responses' => { + '200' => { 'content' => { 'application/json' => {} }, + 'description' => 'option SimpleOptionsTest' } + }, + 'tags' => ['simple-options-test'], + 'operationId' => 'optionsSimpleOptionsTest' + } + }, + '/simple_with_headers' => { + 'get' => { + 'description' => 'this gets something else', + 'operationId' => 'getSimpleWithHeaders', + 'parameters' => [ + { 'description' => 'A required header.', + 'in' => 'header', + 'name' => 'XAuthToken', + 'required' => true, + 'schema' => { 'type' => 'string' } }, + { + 'description' => 'An optional header.', + 'in' => 'header', + 'name' => 'XOtherHeader', + 'required' => false, + 'schema' => { 'type' => 'string' } + } + ], + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, + 'description' => 'this gets something else' }, + '403' => { 'content' => { 'application/json' => {} }, + 'description' => 'invalid pony' }, + '405' => { 'content' => { 'application/json' => {} }, + 'description' => 'no ponies left!' } }, + 'tags' => ['simple_with_headers'] + } + }, + '/custom' => { + 'post' => { + 'description' => 'this uses a custom parameter', + 'operationId' => 'postCustom', + 'requestBody' => { + 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'custom' => { + 'description' => 'array of items', + 'items' => { 'type' => 'CustomType' }, + 'type' => 'array' + } + }, + 'type' => 'object' + } + } + } + }, 'responses' => { '201' => { 'description' => 'this uses a custom parameter' } }, 'tags' => ['custom'] + } + }, + '/items' => { + 'post' => { + 'description' => 'this takes an array of parameters', + 'operationId' => 'postItems', + 'requestBody' => { + 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'items[]' => { + 'description' => 'array of items', + 'items' => { 'type' => 'string' }, + 'type' => 'array' + } + }, 'type' => 'object' + } + } + } + }, + 'responses' => { + '201' => { 'description' => 'this takes an array of parameters' } + }, + 'tags' => ['items'] + } + } + } + ) + end + end + + describe 'retrieves the documentation for mounted-api' do + subject do + get '/swagger_doc/simple.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + # 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'], + 'servers' => [ + 'url' => 'http://example.org' + ], + 'tags' => [ + { 'name' => 'simple', 'description' => 'Operations about simples' } + ], + 'paths' => { + '/simple' => { + 'get' => { + 'description' => 'This gets something.', + 'tags' => ['simple'], + 'operationId' => 'getSimple', + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something.' } } + } + } + } + ) + end + end + + describe 'retrieves the documentation for mounted-api that' do + describe "contains '-' in URL" do + subject do + get '/swagger_doc/simple-test.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'openapi' => '3.0.0', + # 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'], + 'servers' => [ + 'url' => 'http://example.org' + ], + 'tags' => [ + { 'name' => 'simple-test', 'description' => 'Operations about simple-tests' } + ], + 'paths' => { + '/simple-test' => { + 'get' => { + 'description' => 'This gets something for URL using - separator.', + 'tags' => ['simple-test'], + 'operationId' => 'getSimpleTest', + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something for URL using - separator.' } } + } + } + } + ) + end + end + + describe 'includes headers' do + subject do + get '/swagger_doc/simple_with_headers.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']).to eq( + '/simple_with_headers' => { + 'get' => { + 'description' => 'this gets something else', + 'parameters' => [ + { 'in' => 'header', 'name' => 'XAuthToken', 'description' => 'A required header.', 'schema' => { 'type' => 'string' }, 'required' => true }, + { 'in' => 'header', 'name' => 'XOtherHeader', 'description' => 'An optional header.', 'schema' => { 'type' => 'string' }, 'required' => false } + ], + 'tags' => ['simple_with_headers'], + 'operationId' => 'getSimpleWithHeaders', + 'responses' => { + '200' => { 'content' => { 'application/json' => {} }, 'description' => 'this gets something else' }, + '403' => { 'content' => { 'application/json' => {} }, 'description' => 'invalid pony' }, + '405' => { 'content' => { 'application/json' => {} }, 'description' => 'no ponies left!' } + } + } + } + ) + end + end + + describe 'supports array params' do + subject do + get '/swagger_doc/items.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']).to eq( + '/items' => { + 'post' => { + 'description' => 'this takes an array of parameters', + 'requestBody' => { + 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'items[]' => { + 'description' => 'array of items', + 'items' => { 'type' => 'string' }, + 'type' => 'array' + } + }, + 'type' => 'object' + } + } + } + }, + 'tags' => ['items'], + 'operationId' => 'postItems', + 'responses' => { '201' => { 'description' => 'this takes an array of parameters' } } + } + } + ) + end + end + + # TODO: Rendering a custom param type the way it is done here is not valid OpenAPI + # (nor I believe it is valid Swagger 2.0). We should render such a type with a JSON reference + # under components/schemas. + describe 'supports custom params types' do + subject do + get '/swagger_doc/custom.json' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']).to eq( + '/custom' => { + 'post' => { + 'description' => 'this uses a custom parameter', + 'operationId' => 'postCustom', + 'requestBody' => { + 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'custom' => { + 'description' => 'array of items', + 'items' => { 'type' => 'CustomType' }, + 'type' => 'array' + } + }, + 'type' => 'object' + } + } + } + }, 'responses' => { '201' => { 'description' => 'this uses a custom parameter' } }, 'tags' => ['custom'] + } + } + ) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e236c8ca..1370f2ce 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -25,8 +25,18 @@ config.include RSpec::Matchers config.mock_with :rspec config.include Rack::Test::Methods + config.include ApiClassDefinitionCleaner config.raise_errors_for_deprecations! + config.define_derived_metadata(file_path: /spec\/openapi_3/) do |metadata| + metadata[:type] ||= :openapi3 + end + config.define_derived_metadata(file_path: /spec\/swagger_v2/) do |metadata| + metadata[:type] ||= :swagger2 + end + + config.include OpenAPI3ResponseValidationHelper, type: :openapi3 + config.order = 'random' config.seed = 40_834 end diff --git a/spec/support/api_class_definition_cleaner.rb b/spec/support/api_class_definition_cleaner.rb new file mode 100644 index 00000000..9771a93a --- /dev/null +++ b/spec/support/api_class_definition_cleaner.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# ApiClassDefinitionCleaner +# +# This module is designed to be included in the RSpec configuration. +# It provides hooks to track and remove classes that inherit from Grape::API, +# ensuring that no leftover classes interfere with subsequent tests. +module ApiClassDefinitionCleaner + # An array to store the initial state of classes inheriting from Grape::API. + # @return [Array] + @initial_objects = [] + + class << self + # Accessor method to get and set the initial state of classes inheriting from Grape::API. + # + # @return [Array] the array of initial classes inheriting from Grape::API. + attr_accessor :initial_objects + end + + # Sets up before and after hooks to track and clean up classes inheriting from Grape::API. + # + # @param config [RSpec::Core::Configuration] The RSpec configuration object. + def self.included(config) + # Hook to run before all examples. + # Tracks the initial state of classes inheriting from Grape::API. + config.before(:all) do + ApiClassDefinitionCleaner.initial_objects = ObjectSpace + .each_object(Class) + .select { |klass| klass < Grape::API } + end + + # Hook to run after all examples. + # Identifies and removes any new classes inheriting from Grape::API + # that were defined during the examples, to ensure a clean state. + config.after(:all) do + current_objects = ObjectSpace + .each_object(Class) + .select { |klass| klass < Grape::API } + new_objects = current_objects - ApiClassDefinitionCleaner.initial_objects + next if new_objects.empty? + + new_objects.each do |object| + # Skip anonymous class definitions + next if object.name.nil? + + parts = object.to_s.split('::') + parent = parts.size > 1 ? Object.const_get(parts[0..-2].join('::')) : Object + parent.send(:remove_const, parts.last.to_sym) if parent.const_defined?(parts.last.to_sym) + end + + # Run garbage collection to ensure they are removed + GC.start + end + end +end diff --git a/spec/support/model_parsers/mock_parser.rb b/spec/support/model_parsers/mock_parser.rb index ddbb3573..c931a2f3 100644 --- a/spec/support/model_parsers/mock_parser.rb +++ b/spec/support/model_parsers/mock_parser.rb @@ -11,7 +11,7 @@ def documentation id: { type: Integer, desc: 'Identity of Something' }, text: { type: String, desc: 'Content of something.' }, links: { type: 'link', is_array: true }, - others: { type: 'text', is_array: false } + others: { type: 'string', is_array: false } } end end @@ -192,6 +192,229 @@ class ApiResponse < OpenStruct; end } end + let(:openapi_json) do + { + 'components' => { + 'schemas' => { + 'ApiError' => { + 'description' => 'This gets Things.', + 'properties' => { 'mock_data' => { 'description' => "it's a mock", 'type' => 'string' } }, + 'type' => 'object' + }, + 'QueryInput' => { + 'description' => 'nested route inside namespace', + 'properties' => { + 'mock_data' => { 'description' => "it's a mock", 'type' => 'string' } + }, + 'type' => 'object' + }, 'Something' => { + 'description' => 'This gets Things.', + 'properties' => { + 'mock_data' => { 'description' => "it's a mock", 'type' => 'string' } + }, + 'type' => 'object' + } + } + }, + 'info' => { + 'contact' => { + 'email' => 'Contact@email.com', + 'name' => 'Contact name', + 'url' => 'www.The-Contact-URL.org' + }, + 'description' => 'A description of the API.', + 'license' => { + 'name' => 'The name of the license.', + 'url' => 'www.The-URL-of-the-license.org' + }, + 'termsOfService' => 'www.The-URL-of-the-terms-and-service.com', + 'title' => 'The API title to be displayed on the API homepage.', + 'version' => '0.0.1' + }, + 'openapi' => '3.0.0', + 'paths' => { + '/dummy/{id}' => { + 'delete' => { + 'description' => 'dummy route.', + 'operationId' => 'deleteDummyId', + 'parameters' => [{ + 'in' => 'path', 'name' => 'id', 'required' => true, 'schema' => { 'format' => 'int32', 'type' => 'integer' } + }], + 'responses' => { + '204' => { 'description' => 'dummy route.' }, + '401' => { 'content' => { 'application/json' => {} }, 'description' => 'Unauthorized' } + }, + 'tags' => ['dummy'] + } + }, '/thing' => { + 'get' => { + 'description' => 'This gets Things.', + 'operationId' => 'getThing', + 'parameters' => [ + { 'description' => 'Identity of Something', + 'in' => 'query', + 'name' => 'id', + 'required' => false, + 'schema' => { 'format' => 'int32', 'type' => 'integer' } }, + { 'description' => 'Content of something.', + 'in' => 'query', + 'name' => 'text', + 'required' => false, + 'schema' => { 'type' => 'string' } }, + { 'in' => 'query', + 'name' => 'others', + 'required' => false, + 'schema' => { 'type' => 'string' } } + ], + 'responses' => { + '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'This gets Things.' + }, + '401' => { + 'content' => { 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/ApiError' } } }, + 'description' => 'Unauthorized' + } + }, 'tags' => ['thing'] + }, 'post' => { + 'description' => 'This creates Thing.', + 'operationId' => 'postThing', + 'requestBody' => { + 'content' => { + 'application/json' => { + 'schema' => { 'properties' => {}, 'type' => 'object' } + }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'links' => { 'items' => { 'type' => 'string' }, 'type' => 'array' }, + 'text' => { 'description' => 'Content of something.', 'type' => 'string' } + }, + 'required' => %w[text links], + 'type' => 'object' + } + } + } + }, + 'responses' => { + '201' => { 'description' => 'This creates Thing.' }, + '422' => { 'content' => { 'application/json' => {} }, 'description' => 'Unprocessible Entity' } + }, + 'tags' => ['thing'] + } + }, + '/thing/{id}' => { + 'delete' => { + 'description' => 'This deletes Thing.', + 'operationId' => 'deleteThingId', + 'parameters' => [{ + 'in' => 'path', + 'name' => 'id', + 'required' => true, + 'schema' => { 'format' => 'int32', 'type' => 'integer' } + }], + 'responses' => { + '200' => { + 'content' => { 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/Something' } } }, + 'description' => 'This deletes Thing.' + } + }, + 'tags' => ['thing'] + }, + 'get' => { + 'description' => 'This gets Thing.', + 'operationId' => 'getThingId', + 'parameters' => [{ + 'in' => 'path', + 'name' => 'id', + 'required' => true, + 'schema' => { 'format' => 'int32', 'type' => 'integer' } + }], + 'responses' => { + '200' => { 'content' => { 'application/json' => {} }, 'description' => 'getting a single thing' }, + '401' => { 'content' => { 'application/json' => {} }, 'description' => 'Unauthorized' } + }, + 'tags' => ['thing'] + }, + 'put' => { + 'description' => 'This updates Thing.', + 'operationId' => 'putThingId', + 'parameters' => [{ + 'in' => 'path', + 'name' => 'id', + 'required' => true, + 'schema' => { 'format' => 'int32', 'type' => 'integer' } + }], + 'requestBody' => { + 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'links' => { 'items' => { 'type' => 'string' }, 'type' => 'array' }, + 'text' => { 'description' => 'Content of something.', 'type' => 'string' } + }, + 'type' => 'object' + } + } + } + }, + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/Something' } } + }, + 'description' => 'This updates Thing.' + } + }, + 'tags' => ['thing'] + } + }, + '/thing2' => { + 'get' => { + 'description' => 'This gets Things.', + 'operationId' => 'getThing2', + 'responses' => { + '200' => { + 'content' => { 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/Something' } } }, + 'description' => 'get Horses' + }, + '401' => { + 'content' => { + 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/ApiError' } } + }, + 'description' => 'HorsesOutError' + } + }, 'tags' => ['thing2'] + } + }, + '/v3/other_thing/{elements}' => { + 'get' => { + 'description' => 'nested route inside namespace', + 'operationId' => 'getV3OtherThingElements', + 'responses' => { + '200' => { + 'content' => { + 'application/json' => { 'schema' => { '$ref' => '#/components/schemas/QueryInput' } } + }, + 'description' => 'nested route inside namespace' + } + }, 'tags' => ['other_thing'], + 'x-amazon-apigateway-auth' => { 'type' => 'none' }, + 'x-amazon-apigateway-integration' => { 'httpMethod' => 'get', 'type' => 'aws', 'uri' => 'foo_bar_uri' } + } + } + }, + 'servers' => [{ 'url' => 'http://example.org/api' }], + 'tags' => [ + { 'description' => 'Operations about other_things', 'name' => 'other_thing' }, + { 'description' => 'Operations about things', 'name' => 'thing' }, + { 'description' => 'Operations about thing2s', 'name' => 'thing2' }, + { 'description' => 'Operations about dummies', 'name' => 'dummy' } + ] + } + end + let(:swagger_json) do { 'info' => { @@ -233,7 +456,7 @@ class ApiResponse < OpenStruct; end { 'in' => 'query', 'name' => 'id', 'description' => 'Identity of Something', 'type' => 'integer', 'format' => 'int32', 'required' => false }, { 'in' => 'query', 'name' => 'text', 'description' => 'Content of something.', 'type' => 'string', 'required' => false }, { 'in' => 'formData', 'name' => 'links', 'type' => 'array', 'items' => { 'type' => 'link' }, 'required' => false }, - { 'in' => 'query', 'name' => 'others', 'type' => 'text', 'required' => false } + { 'in' => 'query', 'name' => 'others', 'type' => 'string', 'required' => false } ], 'responses' => { '200' => { 'description' => 'This gets Things.' }, '401' => { 'description' => 'Unauthorized', 'schema' => { '$ref' => '#/definitions/ApiError' } } }, 'tags' => ['thing'], diff --git a/spec/support/open3_response_validation_helper.rb b/spec/support/open3_response_validation_helper.rb new file mode 100644 index 00000000..be7301e5 --- /dev/null +++ b/spec/support/open3_response_validation_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'openapi3_parser' + +# This module helps to validate the response body of endpoint tests +# against an OpenAPI 3.0 schema. +module OpenAPI3ResponseValidationHelper + include RSpec::Matchers + + # Sets up an `after` hook to validate the response after each test example. + # + # @param base [Class] the class including this module + def self.included(base) + base.after(:each) do + next unless last_response + next unless last_response.ok? + + validate_openapi3_response(last_response.body) + end + end + + # Validates the response body against an OpenAPI 3.0 schema. + # + # @param response_body [String] the response body to be validated + def validate_openapi3_response(response_body) + document = Openapi3Parser.load(response_body) + return if document.valid? + + aggregate_failures 'validation against an OpenAPI3' do + document.errors.errors.each do |error| + expect(document.valid?).to be(true), "#{error.message} in context #{error.context}" + end + end + end +end