From 4604e1a082e1adececf896332b2441ef1df3f9fb Mon Sep 17 00:00:00 2001 From: blake Date: Sat, 13 Oct 2018 14:05:00 +0200 Subject: [PATCH 01/55] wip: openapi 3.0 --- lib/grape-swagger.rb | 39 ++- lib/grape-swagger/doc_methods.rb | 2 + lib/grape-swagger/doc_methods_openapi_3.rb | 128 ++++++++ lib/grape-swagger/openapi_3/endpoint.rb | 354 +++++++++++++++++++++ lib/grape-swagger/openapi_3/openapi3.rb | 36 +++ lib/grape-swagger/swagger_2/swagger2.rb | 37 +++ spec/openapi_3/simple_mounted_api_spec.rb | 331 +++++++++++++++++++ 7 files changed, 923 insertions(+), 4 deletions(-) create mode 100644 lib/grape-swagger/doc_methods_openapi_3.rb create mode 100644 lib/grape-swagger/openapi_3/endpoint.rb create mode 100644 lib/grape-swagger/openapi_3/openapi3.rb create mode 100644 lib/grape-swagger/swagger_2/swagger2.rb create mode 100644 spec/openapi_3/simple_mounted_api_spec.rb diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index 76474a19..aaff9481 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -5,10 +5,6 @@ require 'grape-swagger/instance' require 'grape-swagger/version' -require 'grape-swagger/endpoint' -require 'grape-swagger/errors' - -require 'grape-swagger/doc_methods' require 'grape-swagger/model_parsers' module GrapeSwagger @@ -20,6 +16,41 @@ def model_parsers autoload :Rake, 'grape-swagger/rake/oapi_tasks' end +def add_swagger_documentation(options = {}) + options = { target_class: self }.merge(options) + + version_for(options) + + documentation_class = if options[:openapi_version] == '3.0' + require 'grape-swagger/openapi_3/openapi3' + OpenApi.new.add_swagger_documentation(options) + else + require 'grape-swagger/swagger_2/swagger2' + Swagger.new.add_swagger_documentation(options) + end + + @target_class = options[:target_class] + + mount(documentation_class) + + @target_class.combined_routes = {} + combine_routes(@target_class, documentation_class) + + @target_class.combined_namespaces = {} + combine_namespaces(@target_class) + + @target_class.combined_namespace_routes = {} + @target_class.combined_namespace_identifiers = {} + combine_namespace_routes(@target_class.combined_namespaces) + + exclusive_route_keys = @target_class.combined_routes.keys - @target_class.combined_namespaces.keys + exclusive_route_keys.each do |key| + @target_class.combined_namespace_routes[key] = @target_class.combined_routes[key] + end + + documentation_class +end + module SwaggerRouting private diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb index efb850e3..7c424084 100644 --- a/lib/grape-swagger/doc_methods.rb +++ b/lib/grape-swagger/doc_methods.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# require 'grape-swagger/endpoint' + require 'grape-swagger/doc_methods/status_codes' require 'grape-swagger/doc_methods/produces_consumes' require 'grape-swagger/doc_methods/data_type' diff --git a/lib/grape-swagger/doc_methods_openapi_3.rb b/lib/grape-swagger/doc_methods_openapi_3.rb new file mode 100644 index 00000000..34eabc86 --- /dev/null +++ b/lib/grape-swagger/doc_methods_openapi_3.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# require 'grape-swagger/openapi_3/endpoint' + +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/doc_methods/parse_params' +require 'grape-swagger/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] + + class_variables_from(options) + + if formatter + %i[format default_format default_error_formatter].each do |method| + send(method, formatter) + end + end + + 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, options) + tags = tags_from(paths, options) + + output[:tags] = tags unless tags.empty? || paths.blank? + output[:paths] = paths unless paths.blank? + output[:definitions] = definitions unless definitions.blank? + + 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 + + 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/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb new file mode 100644 index 00000000..1b64304c --- /dev/null +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -0,0 +1,354 @@ +# frozen_string_literal: true + +require 'active_support' +require 'active_support/core_ext/string/inflections' +require 'grape-swagger/endpoint/params_parser' + +module Grape + class Endpoint + 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(_, request, options) + puts request.inspect + puts options.inspect + + object = { + info: info_object(options[:info].merge(version: options[:doc_version])), + openapi: '3.0.0', + authorizations: options[:authorizations], + securityDefinitions: options[:security_definitions], + security: options[:security], + servers: options[:servers].is_a?(Hash) ? [options[:servers]] : options[:servers] + } + + GrapeSwagger::DocMethods::Extensions.add_extensions_to_root(options, object) + object.delete_if { |_, value| value.blank? } + end + + # building info object + def info_object(infos) + result = { + title: infos[:title] || 'API title', + description: infos[:description], + termsOfServiceUrl: 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 = {} + @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) + method[:produces] = produces_object(route, options[:produces] || options[:format]) + method[:consumes] = consumes_object(route, options[:format]) + method[:parameters] = params_object(route, options, path) + method[:security] = security_object(route) + method[:responses] = response_object(route) + 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) + 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::ParseParams.call(param, value, path, route, @definitions) + end + + if GrapeSwagger::DocMethods::MoveParams.can_be_moved?(parameters, route.request_method) + parameters = GrapeSwagger::DocMethods::MoveParams.to_definition(path, parameters, route, @definitions) + end + + parameters + end + + def response_object(route) + 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] } + + memo[value[:code]][:headers] = value[:headers] if value[:headers] + + next build_file_response(memo[value[:code]]) if file_response?(value[:model]) + + 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 + next unless !response_model.start_with?('Swagger_doc') && (@definitions[response_model] || value[:model]) + + @definitions[response_model][:description] = description_object(route) + + memo[value[:code]][:schema] = build_reference(route, value, response_model) + memo[value[:code]][:examples] = value[:examples] if value[:examples] + 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' => "#/definitions/#{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) + 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}, swagger 2.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/openapi_3/openapi3.rb b/lib/grape-swagger/openapi_3/openapi3.rb new file mode 100644 index 00000000..d4e92e60 --- /dev/null +++ b/lib/grape-swagger/openapi_3/openapi3.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'grape' + +require 'grape-swagger/openapi_3/endpoint' +require 'grape-swagger/errors' + +require 'grape-swagger/doc_methods_openapi_3' +require 'grape-swagger/model_parsers' + +module GrapeSwagger + class << self + def model_parsers + @model_parsers ||= GrapeSwagger::ModelParsers.new + end + end + autoload :Rake, 'grape-swagger/rake/oapi_tasks' +end + +module Grape + class OpenApi + def add_swagger_documentation(options = {}) + documentation_class = create_documentation_class + documentation_class.setup(options) + documentation_class + end + + private + + def create_documentation_class + Class.new(Grape::API) do + extend GrapeOpenAPI::DocMethods + end + end + end +end diff --git a/lib/grape-swagger/swagger_2/swagger2.rb b/lib/grape-swagger/swagger_2/swagger2.rb new file mode 100644 index 00000000..22dc5f5c --- /dev/null +++ b/lib/grape-swagger/swagger_2/swagger2.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'grape' + +require 'grape-swagger/version' +require 'grape-swagger/endpoint' +require 'grape-swagger/errors' + +require 'grape-swagger/doc_methods' +require 'grape-swagger/model_parsers' + +module GrapeSwagger + class << self + def model_parsers + @model_parsers ||= GrapeSwagger::ModelParsers.new + end + end + autoload :Rake, 'grape-swagger/rake/oapi_tasks' +end + +module Grape + class Swagger + def add_swagger_documentation(options = {}) + documentation_class = create_documentation_class + documentation_class.setup(options) + documentation_class + end + + private + + def create_documentation_class + Class.new(Grape::API) do + extend GrapeSwagger::DocMethods + end + end + 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..93715891 --- /dev/null +++ b/spec/openapi_3/simple_mounted_api_spec.rb @@ -0,0 +1,331 @@ +# 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 } + } + + get '/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', + # 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'], + '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', + 'produces' => ['application/json'], + 'responses' => { '200' => { 'description' => 'Document root' } }, + 'operationId' => 'get' + } + }, + '/simple' => { + 'get' => { + 'description' => 'This gets something.', + 'produces' => ['application/json'], + 'tags' => ['simple'], + 'operationId' => 'getSimple', + 'responses' => { '200' => { 'description' => 'This gets something.' } } + } + }, + '/simple-test' => { + 'get' => { + 'description' => 'This gets something for URL using - separator.', + 'produces' => ['application/json'], + 'tags' => ['simple-test'], + 'operationId' => 'getSimpleTest', + 'responses' => { '200' => { 'description' => 'This gets something for URL using - separator.' } } + } + }, + '/simple-head-test' => { + 'head' => { + 'produces' => ['application/json'], + 'responses' => { '200' => { 'description' => 'head SimpleHeadTest' } }, + 'tags' => ['simple-head-test'], + 'operationId' => 'headSimpleHeadTest' + } + }, + '/simple-options-test' => { + 'options' => { + 'produces' => ['application/json'], + 'responses' => { '200' => { 'description' => 'option SimpleOptionsTest' } }, + 'tags' => ['simple-options-test'], + 'operationId' => 'optionsSimpleOptionsTest' + } + }, + '/simple_with_headers' => { + 'get' => { + 'description' => 'this gets something else', + 'produces' => ['application/json'], + 'parameters' => [ + { 'in' => 'header', 'name' => 'XAuthToken', 'description' => 'A required header.', 'type' => 'string', 'required' => true }, + { 'in' => 'header', 'name' => 'XOtherHeader', 'description' => 'An optional header.', 'type' => 'string', 'required' => false } + ], + 'tags' => ['simple_with_headers'], + 'operationId' => 'getSimpleWithHeaders', + 'responses' => { + '200' => { 'description' => 'this gets something else' }, + '403' => { 'description' => 'invalid pony' }, + '405' => { 'description' => 'no ponies left!' } + } + } + }, + '/items' => { + 'post' => { + 'description' => 'this takes an array of parameters', + 'produces' => ['application/json'], + 'consumes' => ['application/json'], + 'parameters' => [{ 'in' => 'formData', 'name' => 'items[]', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'string' } }], + 'tags' => ['items'], + 'operationId' => 'postItems', + 'responses' => { '201' => { 'description' => 'this takes an array of parameters' } } + } + }, + '/custom' => { + 'get' => { + 'description' => 'this uses a custom parameter', + 'produces' => ['application/json'], + 'parameters' => [{ 'in' => 'formData', 'name' => 'custom', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'CustomType' } }], + 'tags' => ['custom'], + 'operationId' => 'getCustom', + 'responses' => { '200' => { 'description' => 'this uses a custom parameter' } } + } + } + } + ) + 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.', + 'produces' => ['application/json'], + 'tags' => ['simple'], + 'operationId' => 'getSimple', + 'responses' => { '200' => { '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.', + 'produces' => ['application/json'], + 'tags' => ['simple-test'], + 'operationId' => 'getSimpleTest', + 'responses' => { '200' => { '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', + 'produces' => ['application/json'], + 'parameters' => [ + { 'in' => 'header', 'name' => 'XAuthToken', 'description' => 'A required header.', 'type' => 'string', 'required' => true }, + { 'in' => 'header', 'name' => 'XOtherHeader', 'description' => 'An optional header.', 'type' => 'string', 'required' => false } + ], + 'tags' => ['simple_with_headers'], + 'operationId' => 'getSimpleWithHeaders', + 'responses' => { + '200' => { 'description' => 'this gets something else' }, + '403' => { 'description' => 'invalid pony' }, + '405' => { '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', + 'produces' => ['application/json'], + 'consumes' => ['application/json'], + 'parameters' => [{ 'in' => 'formData', 'name' => 'items[]', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'string' } }], + 'tags' => ['items'], + 'operationId' => 'postItems', + 'responses' => { '201' => { 'description' => 'this takes an array of parameters' } } + } + } + ) + end + end + + 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' => { + 'get' => { + 'description' => 'this uses a custom parameter', + 'produces' => ['application/json'], + 'parameters' => [{ 'in' => 'formData', 'name' => 'custom', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'CustomType' } }], + 'tags' => ['custom'], + 'operationId' => 'getCustom', + 'responses' => { '200' => { 'description' => 'this uses a custom parameter' } } + } + } + ) + end + end + end +end From f29e14cd33d8e130a1b1ac7cd6b453e1da4ec8e2 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 14 Oct 2018 14:28:29 +0200 Subject: [PATCH 02/55] move files to respective version folders --- README.md | 2 +- .../{doc_methods_openapi_3.rb => openapi_3/doc_methods.rb} | 0 lib/grape-swagger/openapi_3/endpoint.rb | 5 +---- lib/grape-swagger/openapi_3/openapi3.rb | 4 ++-- lib/grape-swagger/{ => swagger_2}/doc_methods.rb | 0 lib/grape-swagger/{ => swagger_2}/endpoint.rb | 0 lib/grape-swagger/swagger_2/swagger2.rb | 4 ++-- 7 files changed, 6 insertions(+), 9 deletions(-) rename lib/grape-swagger/{doc_methods_openapi_3.rb => openapi_3/doc_methods.rb} (100%) rename lib/grape-swagger/{ => swagger_2}/doc_methods.rb (100%) rename lib/grape-swagger/{ => swagger_2}/endpoint.rb (100%) 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/doc_methods_openapi_3.rb b/lib/grape-swagger/openapi_3/doc_methods.rb similarity index 100% rename from lib/grape-swagger/doc_methods_openapi_3.rb rename to lib/grape-swagger/openapi_3/doc_methods.rb diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 1b64304c..659db896 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -23,10 +23,7 @@ def content_types_for(target_class) # openapi 3.0 related parts # # required keys for SwaggerObject - def swagger_object(_, request, options) - puts request.inspect - puts options.inspect - + def swagger_object(_target_class, _request, options) object = { info: info_object(options[:info].merge(version: options[:doc_version])), openapi: '3.0.0', diff --git a/lib/grape-swagger/openapi_3/openapi3.rb b/lib/grape-swagger/openapi_3/openapi3.rb index d4e92e60..a2b29f9b 100644 --- a/lib/grape-swagger/openapi_3/openapi3.rb +++ b/lib/grape-swagger/openapi_3/openapi3.rb @@ -2,10 +2,10 @@ require 'grape' -require 'grape-swagger/openapi_3/endpoint' require 'grape-swagger/errors' +require 'grape-swagger/openapi_3/endpoint' +require 'grape-swagger/openapi_3/doc_methods' -require 'grape-swagger/doc_methods_openapi_3' require 'grape-swagger/model_parsers' module GrapeSwagger 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 100% rename from lib/grape-swagger/endpoint.rb rename to lib/grape-swagger/swagger_2/endpoint.rb diff --git a/lib/grape-swagger/swagger_2/swagger2.rb b/lib/grape-swagger/swagger_2/swagger2.rb index 22dc5f5c..cbd7c00f 100644 --- a/lib/grape-swagger/swagger_2/swagger2.rb +++ b/lib/grape-swagger/swagger_2/swagger2.rb @@ -3,10 +3,10 @@ require 'grape' require 'grape-swagger/version' -require 'grape-swagger/endpoint' require 'grape-swagger/errors' -require 'grape-swagger/doc_methods' +require 'grape-swagger/swagger_2/endpoint' +require 'grape-swagger/swagger_2/doc_methods' require 'grape-swagger/model_parsers' module GrapeSwagger From ac90d9f042f244c30f9379a321e13f6a1b8c1fd0 Mon Sep 17 00:00:00 2001 From: blake Date: Sat, 20 Oct 2018 23:10:44 +0200 Subject: [PATCH 03/55] begin to parse params --- lib/grape-swagger/openapi_3/doc_methods.rb | 6 +- .../openapi_3/doc_methods/parse_params.rb | 124 +++++++++++++++ lib/grape-swagger/openapi_3/endpoint.rb | 11 +- spec/openapi_3/api_openapi_3_response_spec.rb | 142 ++++++++++++++++++ 4 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 lib/grape-swagger/openapi_3/doc_methods/parse_params.rb create mode 100644 spec/openapi_3/api_openapi_3_response_spec.rb diff --git a/lib/grape-swagger/openapi_3/doc_methods.rb b/lib/grape-swagger/openapi_3/doc_methods.rb index 34eabc86..4c4ad21d 100644 --- a/lib/grape-swagger/openapi_3/doc_methods.rb +++ b/lib/grape-swagger/openapi_3/doc_methods.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# require 'grape-swagger/openapi_3/endpoint' - require 'grape-swagger/doc_methods/status_codes' require 'grape-swagger/doc_methods/produces_consumes' require 'grape-swagger/doc_methods/data_type' @@ -10,7 +8,7 @@ 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/doc_methods/parse_params' +require 'grape-swagger/openapi_3/doc_methods/parse_params' require 'grape-swagger/doc_methods/move_params' require 'grape-swagger/doc_methods/headers' require 'grape-swagger/doc_methods/build_model_definition' @@ -52,7 +50,7 @@ def setup(options) options ) - paths, definitions = endpoint.path_and_definition_objects(combi_routes, 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? 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..203f2efa --- /dev/null +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module GrapeSwagger + module DocMethods + class ParseParams + class << self + def call(param, settings, path, route, definitions) + method = route.request_method + additional_documentation = settings.fetch(:documentation, {}) + settings.merge!(additional_documentation) + data_type = DataType.call(settings) + + value_type = settings.merge(data_type: data_type, path: path, param_name: param, method: method) + + # required properties + @parsed_param = { + in: param_type(value_type), + name: settings[:full_name] || param + } + + # optional properties + document_description(settings) + document_type_and_format(settings, data_type) + document_array_param(value_type, definitions) if value_type[:is_array] + document_default_value(settings) unless value_type[:is_array] + document_range_values(settings) unless value_type[:is_array] + document_required(settings) + + @parsed_param + end + + private + + def document_description(settings) + description = settings[:desc] || settings[:description] + @parsed_param[:description] = description if description + end + + def document_required(settings) + @parsed_param[:required] = settings[:required] || false + @parsed_param[:required] = true if @parsed_param[:in] == 'path' + end + + def document_range_values(settings) + values = settings[:values] || nil + enum_or_range_values = parse_enum_or_range_values(values) + @parsed_param.merge!(enum_or_range_values) if enum_or_range_values + end + + def document_default_value(settings) + @parsed_param[:default] = settings[:default] if settings[:default].present? + end + + def document_type_and_format(settings, data_type) + if DataType.primitive?(data_type) + data = DataType.mapping(data_type) + @parsed_param[:type], @parsed_param[:format] = data + else + @parsed_param[:type] = data_type + end + @parsed_param[: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'] = "#/definitions/#{@parsed_param[:type]}" + else + array_items[:type] = type || @parsed_param[:type] == 'array' ? 'string' : @parsed_param[: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[:type] = 'array' + @parsed_param[:collectionFormat] = collection_format if DataType.collections.include?(collection_format) + end + + def param_type(value_type) + param_type = value_type[:param_type] || value_type[:in] + if value_type[:path].include?("{#{value_type[:param_name]}}") + 'path' + elsif param_type + param_type + elsif %w[POST PUT PATCH].include?(value_type[:method]) + DataType.request_primitive?(value_type[:data_type]) ? 'formData' : 'body' + else + 'query' + end + 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 + parse_range_values(values) if values.first.is_a?(Integer) + else + { enum: values } if values + end + end + + def parse_range_values(values) + { minimum: values.first, maximum: values.last } + end + end + end + end +end diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 659db896..febd3013 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -72,7 +72,9 @@ def contact_object(infos) end # building path and definitions objects - def path_and_definition_objects(namespace_routes, options) + 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| @@ -114,8 +116,8 @@ def method_object(route, options, path) method = {} method[:summary] = summary_object(route) method[:description] = description_object(route) - method[:produces] = produces_object(route, options[:produces] || options[:format]) - method[:consumes] = consumes_object(route, options[:format]) + # method[:produces] = produces_object(route, options[:produces] || options[:format]) + # method[:consumes] = consumes_object(route, options[:format]) method[:parameters] = params_object(route, options, path) method[:security] = security_object(route) method[:responses] = response_object(route) @@ -216,8 +218,9 @@ def response_object(route) next unless !response_model.start_with?('Swagger_doc') && (@definitions[response_model] || value[:model]) @definitions[response_model][:description] = description_object(route) + ref = build_reference(route, value, response_model) - memo[value[:code]][:schema] = build_reference(route, value, response_model) + memo[value[:code]][:content] = @content_types.map { |c| [c, { schema: ref }] }.to_h memo[value[:code]][:examples] = value[:examples] if value[:examples] 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..95e54930 --- /dev/null +++ b/spec/openapi_3/api_openapi_3_response_spec.rb @@ -0,0 +1,142 @@ +# 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' => '#/definitions/UseItemResponseAsType' } + } + } + }, + '400' => { + 'description' => 'NotFound', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/definitions/ApiError' } + } + } + } + }, + 'tags' => ['nested_type'], + 'operationId' => 'getNestedType' + ) + expect(subject['definitions']).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' => '#/definitions/UseResponse' } + } + } + }, + '400' => { + 'description' => 'NotFound', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/definitions/ApiError' } + } + } + } + }, + 'tags' => ['entity_response'], + 'operationId' => 'getEntityResponse' + ) + expect(subject['definitions']).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', + 'parameters' => [ + { 'in' => 'formData', 'name' => 'description', 'type' => 'string', 'required' => false }, + { 'in' => 'formData', 'name' => '$responses', 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => false } + ], + 'responses' => { + '201' => { + 'description' => 'This returns something' + }, + '400' => { + 'description' => 'NotFound', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/definitions/ApiError' } + } + } + } + }, + 'tags' => ['params_given'], + 'operationId' => 'postParamsGiven' + ) + expect(subject['definitions']).to eql(swagger_params_as_response_object) + end + end +end From 6bac65247ff2cd961d62ad25474146b5e547176b Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 1 Nov 2018 16:50:41 +0100 Subject: [PATCH 04/55] fix failing tests --- lib/grape-swagger/openapi_3/endpoint.rb | 26 +++++++++---- spec/openapi_3/simple_mounted_api_spec.rb | 46 ++++++++--------------- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index febd3013..cc8080ba 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -116,11 +116,13 @@ def method_object(route, options, path) method = {} method[:summary] = summary_object(route) method[:description] = description_object(route) - # method[:produces] = produces_object(route, options[:produces] || options[:format]) # method[:consumes] = consumes_object(route, options[:format]) method[:parameters] = params_object(route, options, path) method[:security] = security_object(route) - method[:responses] = response_object(route) + + 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) @@ -194,7 +196,7 @@ def params_object(route, options, path) parameters end - def response_object(route) + 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 } @@ -214,13 +216,23 @@ def response_object(route) value[:code] = 204 end - next if value[:code] == 204 - next unless !response_model.start_with?('Swagger_doc') && (@definitions[response_model] || value[:model]) + next if value[:code] == 204 || value[:code] == 201 + + model = !response_model.start_with?('Swagger_doc') && (@definitions[response_model] || value[:model]) - @definitions[response_model][:description] = description_object(route) ref = build_reference(route, value, response_model) + memo[value[:code]][:content] = content_types.map do |c| + if model + [c, { schema: ref }] + else + [c, {}] + end + end.to_h + + next unless model + + @definitions[response_model][:description] = description_object(route) - memo[value[:code]][:content] = @content_types.map { |c| [c, { schema: ref }] }.to_h memo[value[:code]][:examples] = value[:examples] if value[:examples] end end diff --git a/spec/openapi_3/simple_mounted_api_spec.rb b/spec/openapi_3/simple_mounted_api_spec.rb index 93715891..3548495b 100644 --- a/spec/openapi_3/simple_mounted_api_spec.rb +++ b/spec/openapi_3/simple_mounted_api_spec.rb @@ -90,7 +90,6 @@ def app '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' ], @@ -107,41 +106,36 @@ def app '/' => { 'get' => { 'description' => 'Document root', - 'produces' => ['application/json'], - 'responses' => { '200' => { 'description' => 'Document root' } }, + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'Document root' } }, 'operationId' => 'get' } }, '/simple' => { 'get' => { 'description' => 'This gets something.', - 'produces' => ['application/json'], 'tags' => ['simple'], 'operationId' => 'getSimple', - 'responses' => { '200' => { 'description' => 'This gets something.' } } + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something.' } } } }, '/simple-test' => { 'get' => { 'description' => 'This gets something for URL using - separator.', - 'produces' => ['application/json'], 'tags' => ['simple-test'], 'operationId' => 'getSimpleTest', - 'responses' => { '200' => { 'description' => 'This gets something for URL using - separator.' } } + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something for URL using - separator.' } } } }, '/simple-head-test' => { 'head' => { - 'produces' => ['application/json'], - 'responses' => { '200' => { 'description' => 'head SimpleHeadTest' } }, + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'head SimpleHeadTest' } }, 'tags' => ['simple-head-test'], 'operationId' => 'headSimpleHeadTest' } }, '/simple-options-test' => { 'options' => { - 'produces' => ['application/json'], - 'responses' => { '200' => { 'description' => 'option SimpleOptionsTest' } }, + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'option SimpleOptionsTest' } }, 'tags' => ['simple-options-test'], 'operationId' => 'optionsSimpleOptionsTest' } @@ -149,7 +143,6 @@ def app '/simple_with_headers' => { 'get' => { 'description' => 'this gets something else', - 'produces' => ['application/json'], 'parameters' => [ { 'in' => 'header', 'name' => 'XAuthToken', 'description' => 'A required header.', 'type' => 'string', 'required' => true }, { 'in' => 'header', 'name' => 'XOtherHeader', 'description' => 'An optional header.', 'type' => 'string', 'required' => false } @@ -157,16 +150,15 @@ def app 'tags' => ['simple_with_headers'], 'operationId' => 'getSimpleWithHeaders', 'responses' => { - '200' => { 'description' => 'this gets something else' }, - '403' => { 'description' => 'invalid pony' }, - '405' => { 'description' => 'no ponies left!' } + '200' => { 'content' => { 'application/json' => {} }, 'description' => 'this gets something else' }, + '403' => { 'content' => { 'application/json' => {} }, 'description' => 'invalid pony' }, + '405' => { 'content' => { 'application/json' => {} }, 'description' => 'no ponies left!' } } } }, '/items' => { 'post' => { 'description' => 'this takes an array of parameters', - 'produces' => ['application/json'], 'consumes' => ['application/json'], 'parameters' => [{ 'in' => 'formData', 'name' => 'items[]', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'string' } }], 'tags' => ['items'], @@ -177,11 +169,10 @@ def app '/custom' => { 'get' => { 'description' => 'this uses a custom parameter', - 'produces' => ['application/json'], 'parameters' => [{ 'in' => 'formData', 'name' => 'custom', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'CustomType' } }], 'tags' => ['custom'], 'operationId' => 'getCustom', - 'responses' => { '200' => { 'description' => 'this uses a custom parameter' } } + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'this uses a custom parameter' } } } } } @@ -210,10 +201,9 @@ def app '/simple' => { 'get' => { 'description' => 'This gets something.', - 'produces' => ['application/json'], 'tags' => ['simple'], 'operationId' => 'getSimple', - 'responses' => { '200' => { 'description' => 'This gets something.' } } + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something.' } } } } } @@ -234,7 +224,7 @@ def app 'openapi' => '3.0.0', # 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'], 'servers' => [ - 'url' => 'http://example.org' + 'url' => 'http://example.org' ], 'tags' => [ { 'name' => 'simple-test', 'description' => 'Operations about simple-tests' } @@ -243,10 +233,9 @@ def app '/simple-test' => { 'get' => { 'description' => 'This gets something for URL using - separator.', - 'produces' => ['application/json'], 'tags' => ['simple-test'], 'operationId' => 'getSimpleTest', - 'responses' => { '200' => { 'description' => 'This gets something for URL using - separator.' } } + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'This gets something for URL using - separator.' } } } } } @@ -265,7 +254,6 @@ def app '/simple_with_headers' => { 'get' => { 'description' => 'this gets something else', - 'produces' => ['application/json'], 'parameters' => [ { 'in' => 'header', 'name' => 'XAuthToken', 'description' => 'A required header.', 'type' => 'string', 'required' => true }, { 'in' => 'header', 'name' => 'XOtherHeader', 'description' => 'An optional header.', 'type' => 'string', 'required' => false } @@ -273,9 +261,9 @@ def app 'tags' => ['simple_with_headers'], 'operationId' => 'getSimpleWithHeaders', 'responses' => { - '200' => { 'description' => 'this gets something else' }, - '403' => { 'description' => 'invalid pony' }, - '405' => { 'description' => 'no ponies left!' } + '200' => { 'content' => { 'application/json' => {} }, 'description' => 'this gets something else' }, + '403' => { 'content' => { 'application/json' => {} }, 'description' => 'invalid pony' }, + '405' => { 'content' => { 'application/json' => {} }, 'description' => 'no ponies left!' } } } } @@ -294,7 +282,6 @@ def app '/items' => { 'post' => { 'description' => 'this takes an array of parameters', - 'produces' => ['application/json'], 'consumes' => ['application/json'], 'parameters' => [{ 'in' => 'formData', 'name' => 'items[]', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'string' } }], 'tags' => ['items'], @@ -317,11 +304,10 @@ def app '/custom' => { 'get' => { 'description' => 'this uses a custom parameter', - 'produces' => ['application/json'], 'parameters' => [{ 'in' => 'formData', 'name' => 'custom', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'CustomType' } }], 'tags' => ['custom'], 'operationId' => 'getCustom', - 'responses' => { '200' => { 'description' => 'this uses a custom parameter' } } + 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'this uses a custom parameter' } } } } ) From af9b11d96ec246e8fb6131c08f3f5b9a69ebff8c Mon Sep 17 00:00:00 2001 From: blake Date: Sat, 10 Nov 2018 14:28:34 +0100 Subject: [PATCH 05/55] Initial support for openapi3 requestBody --- .../doc_methods/parse_request_body.rb | 122 ++++++++++++++++++ lib/grape-swagger/openapi_3/endpoint.rb | 36 +++++- spec/openapi_3/params_hash_spec.rb | 83 ++++++++++++ 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb create mode 100644 spec/openapi_3/params_hash_spec.rb diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb new file mode 100644 index 00000000..2269a16c --- /dev/null +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module GrapeSwagger + module DocMethods + class ParseRequestBody + class << self + def call(param, settings, path, route, _definitions) + method = route.request_method + additional_documentation = settings.fetch(:documentation, {}) + settings.merge!(additional_documentation) + data_type = DataType.call(settings) + + value_type = settings.merge(data_type: data_type, path: path, param_name: param, method: method) + + type = param_type(value_type) + return nil if type.nil? + + # required properties + @parsed_param = { + in: type, + name: settings[:full_name] || param + } + + # optional properties + document_description(settings) + document_type_and_format(settings, data_type) + document_array_param(value_type, definitions) if value_type[:is_array] + document_default_value(settings) unless value_type[:is_array] + document_range_values(settings) unless value_type[:is_array] + document_required(settings) + + @parsed_param + end + + private + + def document_description(settings) + description = settings[:desc] || settings[:description] + @parsed_param[:description] = description if description + end + + def document_required(settings) + @parsed_param[:required] = settings[:required] || false + @parsed_param[:required] = true if @parsed_param[:in] == 'path' + end + + def document_range_values(settings) + values = settings[:values] || nil + enum_or_range_values = parse_enum_or_range_values(values) + @parsed_param.merge!(enum_or_range_values) if enum_or_range_values + end + + def document_default_value(settings) + @parsed_param[:default] = settings[:default] if settings[:default].present? + end + + def document_type_and_format(settings, data_type) + if DataType.primitive?(data_type) + data = DataType.mapping(data_type) + @parsed_param[:type], @parsed_param[:format] = data + else + @parsed_param[:type] = data_type + end + @parsed_param[: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'] = "#/definitions/#{@parsed_param[:type]}" + else + array_items[:type] = type || @parsed_param[:type] == 'array' ? 'string' : @parsed_param[: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[:type] = 'array' + @parsed_param[:collectionFormat] = collection_format if DataType.collections.include?(collection_format) + end + + def param_type(value_type) + if value_type[:path].include?("{#{value_type[:param_name]}}") + nil + elsif %w[POST PUT PATCH].include?(value_type[:method]) + DataType.request_primitive?(value_type[:data_type]) ? 'formData' : 'body' + end + 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 + parse_range_values(values) if values.first.is_a?(Integer) + else + { enum: values } if values + end + end + + def parse_range_values(values) + { minimum: values.first, maximum: values.last } + end + end + end + end +end diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index cc8080ba..f6cd9248 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -3,6 +3,7 @@ require 'active_support' require 'active_support/core_ext/string/inflections' require 'grape-swagger/endpoint/params_parser' +require 'grape-swagger/openapi_3/doc_methods/parse_request_body' module Grape class Endpoint @@ -116,10 +117,13 @@ def method_object(route, options, path) method = {} method[:summary] = summary_object(route) method[:description] = description_object(route) - # method[:consumes] = consumes_object(route, options[:format]) method[:parameters] = params_object(route, options, path) method[:security] = security_object(route) + if %w[POST PUT PATCH].include?(route.request_method) + method[:requestBody] = response_body_object(route, options, path) + end + # method[:consumes] = consumes_object(route, options[:format]) produces = produces_object(route, options[:produces] || options[:format]) method[:responses] = response_object(route, produces) @@ -196,6 +200,36 @@ def params_object(route, options, path) parameters end + def response_body_object(route, options, path) + 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::ParseRequestBody.call(param, value, path, route, @definitions) + end.flatten + + parameters = { + 'content' => parameters.group_by { |p| p[:in] }.map do |_k, v| + required_values = v.select { |param| param[:required] } + [ + 'application/x-www-form-urlencoded', + { 'schema' => { + 'type' => 'object', + 'required' => required_values.map { |required| required[:name] }, + 'properties' => v.map { |value| [value[:name], value.except(:name, :in, :required)] }.to_h + } } + ] + end.to_h + } + + parameters + 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 } diff --git a/spec/openapi_3/params_hash_spec.rb b/spec/openapi_3/params_hash_spec.rb new file mode 100644 index 00000000..ebf44a9f --- /dev/null +++ b/spec/openapi_3/params_hash_spec.rb @@ -0,0 +1,83 @@ +# 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/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/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 From c266c5bb8e90086acfe1c1b1a333c7fff0565eb6 Mon Sep 17 00:00:00 2001 From: blake Date: Mon, 12 Nov 2018 17:21:26 +0100 Subject: [PATCH 06/55] OpenAPI3 parameters and request bodies are actually OpenAPI3 compliant --- .../openapi_3/doc_methods/parse_params.rb | 7 +- lib/grape-swagger/openapi_3/endpoint.rb | 25 ++---- spec/openapi_3/param_multi_type_spec.rb | 81 +++++++++++++++++++ 3 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 spec/openapi_3/param_multi_type_spec.rb diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb index 203f2efa..1b4f0c2f 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -52,13 +52,14 @@ def document_default_value(settings) end def document_type_and_format(settings, data_type) + @parsed_param[:schema] = {} if DataType.primitive?(data_type) data = DataType.mapping(data_type) - @parsed_param[:type], @parsed_param[:format] = data + @parsed_param[:schema][:type], @parsed_param[:schema][:format] = data else - @parsed_param[:type] = data_type + @parsed_param[:schema][:type] = data_type end - @parsed_param[:format] = settings[:format] if settings[:format].present? + @parsed_param[:schema][:format] = settings[:format] if settings[:format].present? end def document_array_param(value_type, definitions) diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index f6cd9248..f0806fbc 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -117,13 +117,15 @@ def method_object(route, options, path) method = {} method[:summary] = summary_object(route) method[:description] = description_object(route) - method[:parameters] = params_object(route, options, path) + + parameters = params_object(route, options, path).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, options, path) + method[:requestBody] = response_body_object(route, path, parameters.first) end - # method[:consumes] = consumes_object(route, options[:format]) produces = produces_object(route, options[:produces] || options[:format]) method[:responses] = response_object(route, produces) @@ -200,28 +202,17 @@ def params_object(route, options, path) parameters end - def response_body_object(route, options, path) - 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::ParseRequestBody.call(param, value, path, route, @definitions) - end.flatten - + def response_body_object(_, _, parameters) parameters = { 'content' => parameters.group_by { |p| p[:in] }.map do |_k, v| + properties = v.map { |value| [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] }.to_h required_values = v.select { |param| param[:required] } [ 'application/x-www-form-urlencoded', { 'schema' => { 'type' => 'object', 'required' => required_values.map { |required| required[:name] }, - 'properties' => v.map { |value| [value[:name], value.except(:name, :in, :required)] }.to_h + 'properties' => properties } } ] end.to_h 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..f83f6864 --- /dev/null +++ b/spec/openapi_3/param_multi_type_spec.rb @@ -0,0 +1,81 @@ +# 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/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 From c7170da43f8a0f728b12d223099900a9c37f4da2 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 16 Nov 2018 16:06:48 +0100 Subject: [PATCH 07/55] Fix array parameter type (not custom types for now) --- .../openapi_3/doc_methods/parse_params.rb | 4 +- lib/grape-swagger/openapi_3/endpoint.rb | 13 +-- spec/openapi_3/simple_mounted_api_spec.rb | 106 +++++++++++++----- 3 files changed, 82 insertions(+), 41 deletions(-) diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb index 1b4f0c2f..fd23695e 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -76,7 +76,7 @@ def document_array_param(value_type, definitions) if definitions[value_type[:data_type]] array_items['$ref'] = "#/definitions/#{@parsed_param[:type]}" else - array_items[:type] = type || @parsed_param[:type] == 'array' ? 'string' : @parsed_param[:type] + 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] @@ -88,7 +88,7 @@ def document_array_param(value_type, definitions) @parsed_param[:in] = param_type || 'formData' @parsed_param[:items] = array_items - @parsed_param[:type] = 'array' + @parsed_param[:schema][:type] = 'array' @parsed_param[:collectionFormat] = collection_format if DataType.collections.include?(collection_format) end diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index f0806fbc..b2da4981 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -206,15 +206,10 @@ def response_body_object(_, _, parameters) parameters = { 'content' => parameters.group_by { |p| p[:in] }.map do |_k, v| properties = v.map { |value| [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] }.to_h - required_values = v.select { |param| param[:required] } - [ - 'application/x-www-form-urlencoded', - { 'schema' => { - 'type' => 'object', - 'required' => required_values.map { |required| required[:name] }, - 'properties' => properties - } } - ] + required_values = v.select { |param| param[:required] }.map { |required| required[:name] } + result = { 'schema' => { 'type' => 'object', 'properties' => properties } } + result['required'] = required_values unless required_values.empty? + ['application/x-www-form-urlencoded', result] end.to_h } diff --git a/spec/openapi_3/simple_mounted_api_spec.rb b/spec/openapi_3/simple_mounted_api_spec.rb index 3548495b..a8755e8c 100644 --- a/spec/openapi_3/simple_mounted_api_spec.rb +++ b/spec/openapi_3/simple_mounted_api_spec.rb @@ -135,7 +135,10 @@ def app }, '/simple-options-test' => { 'options' => { - 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'option SimpleOptionsTest' } }, + 'responses' => { + '200' => { 'content' => { 'application/json' => {} }, + 'description' => 'option SimpleOptionsTest' } + }, 'tags' => ['simple-options-test'], 'operationId' => 'optionsSimpleOptionsTest' } @@ -143,36 +146,64 @@ def app '/simple_with_headers' => { 'get' => { 'description' => 'this gets something else', + 'operationId' => 'getSimpleWithHeaders', 'parameters' => [ - { 'in' => 'header', 'name' => 'XAuthToken', 'description' => 'A required header.', 'type' => 'string', 'required' => true }, - { 'in' => 'header', 'name' => 'XOtherHeader', 'description' => 'An optional header.', 'type' => 'string', 'required' => false } + { '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' } + } ], - '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!' } - } - } - }, - '/items' => { - 'post' => { - 'description' => 'this takes an array of parameters', - 'consumes' => ['application/json'], - 'parameters' => [{ 'in' => 'formData', 'name' => 'items[]', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'string' } }], - 'tags' => ['items'], - 'operationId' => 'postItems', - 'responses' => { '201' => { 'description' => 'this takes an array of parameters' } } + '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' => { 'get' => { 'description' => 'this uses a custom parameter', - 'parameters' => [{ 'in' => 'formData', 'name' => 'custom', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'CustomType' } }], - 'tags' => ['custom'], 'operationId' => 'getCustom', - 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'this uses a custom parameter' } } + 'responses' => { '200' => { + 'content' => { 'application/json' => {} }, + 'description' => 'this uses a custom parameter' + } }, + 'tags' => ['custom'] + } + }, + '/items' => { + 'post' => { + 'description' => 'this takes an array of parameters', + 'operationId' => 'postItems', + 'requestBody' => { + 'content' => { + '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'] } } } @@ -255,8 +286,8 @@ def app 'get' => { 'description' => 'this gets something else', 'parameters' => [ - { 'in' => 'header', 'name' => 'XAuthToken', 'description' => 'A required header.', 'type' => 'string', 'required' => true }, - { 'in' => 'header', 'name' => 'XOtherHeader', 'description' => 'An optional header.', 'type' => 'string', 'required' => false } + { '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', @@ -282,8 +313,20 @@ def app '/items' => { 'post' => { 'description' => 'this takes an array of parameters', - 'consumes' => ['application/json'], - 'parameters' => [{ 'in' => 'formData', 'name' => 'items[]', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'string' } }], + 'requestBody' => { + 'content' => { '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' } } @@ -300,14 +343,17 @@ def app end specify do + fail("TODO: Fix") expect(subject['paths']).to eq( '/custom' => { 'get' => { 'description' => 'this uses a custom parameter', - 'parameters' => [{ 'in' => 'formData', 'name' => 'custom', 'description' => 'array of items', 'required' => false, 'type' => 'array', 'items' => { 'type' => 'CustomType' } }], - 'tags' => ['custom'], 'operationId' => 'getCustom', - 'responses' => { '200' => { 'content' => { 'application/json' => {} }, 'description' => 'this uses a custom parameter' } } + 'responses' => { + '200' => { 'content' => { 'application/json' => {} }, + 'description' => 'this uses a custom parameter' } + }, + 'tags' => ['custom'] } } ) From 94a3fee7f62454f6559a727cc6d09d24d97df243 Mon Sep 17 00:00:00 2001 From: blake Date: Sat, 17 Nov 2018 08:47:19 +0100 Subject: [PATCH 08/55] add tests --- lib/grape-swagger/openapi_3/endpoint.rb | 2 +- spec/openapi_3/api_openapi_3_response_spec.rb | 20 +++- spec/openapi_3/param_type_spec.rb | 94 +++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 spec/openapi_3/param_type_spec.rb diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index b2da4981..31cb7f4c 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -208,7 +208,7 @@ def response_body_object(_, _, parameters) properties = v.map { |value| [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] }.to_h required_values = v.select { |param| param[:required] }.map { |required| required[:name] } result = { 'schema' => { 'type' => 'object', 'properties' => properties } } - result['required'] = required_values unless required_values.empty? + result['schema']['required'] = required_values unless required_values.empty? ['application/x-www-form-urlencoded', result] end.to_h } diff --git a/spec/openapi_3/api_openapi_3_response_spec.rb b/spec/openapi_3/api_openapi_3_response_spec.rb index 95e54930..c724848b 100644 --- a/spec/openapi_3/api_openapi_3_response_spec.rb +++ b/spec/openapi_3/api_openapi_3_response_spec.rb @@ -114,12 +114,24 @@ def app end specify do + fail("TODO: Fix") expect(subject['paths']['/params_given']['post']).to eql( 'description' => 'This returns something', - 'parameters' => [ - { 'in' => 'formData', 'name' => 'description', 'type' => 'string', 'required' => false }, - { 'in' => 'formData', 'name' => '$responses', 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => false } - ], + 'requestBody' => { + 'content' => { + 'application/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + '$responses' => { + 'items' => { 'type' => 'string' }, 'type' => 'array' + }, + 'description' => { 'type' => 'string' } + }, + 'type' => 'object' + } + } + } + }, 'responses' => { '201' => { 'description' => 'This returns something' diff --git a/spec/openapi_3/param_type_spec.rb b/spec/openapi_3/param_type_spec.rb new file mode 100644 index 00000000..7b042c9b --- /dev/null +++ b/spec/openapi_3/param_type_spec.rb @@ -0,0 +1,94 @@ +# 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/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/x-www-form-urlencoded' => { + 'schema' => { + 'properties' => { + 'input' => { 'default' => '42', 'format' => 'email', 'type' => 'string' } + }, + 'required' => ['input'], 'type' => 'object' + } + } + } + end + end +end From 3189dfa7ac49065bd8c556d55549e69474443f52 Mon Sep 17 00:00:00 2001 From: blake Date: Sat, 17 Nov 2018 21:09:52 +0100 Subject: [PATCH 09/55] Initial support for openapi3 components/schemas --- lib/grape-swagger/openapi_3/doc_methods.rb | 5 ++++- .../openapi_3/doc_methods/parse_params.rb | 2 +- lib/grape-swagger/openapi_3/endpoint.rb | 2 +- spec/openapi_3/api_openapi_3_response_spec.rb | 17 ++++++++--------- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/grape-swagger/openapi_3/doc_methods.rb b/lib/grape-swagger/openapi_3/doc_methods.rb index 4c4ad21d..1907edfd 100644 --- a/lib/grape-swagger/openapi_3/doc_methods.rb +++ b/lib/grape-swagger/openapi_3/doc_methods.rb @@ -55,7 +55,10 @@ def setup(options) output[:tags] = tags unless tags.empty? || paths.blank? output[:paths] = paths unless paths.blank? - output[:definitions] = definitions unless definitions.blank? + unless definitions.blank? + output[:components] = {} + output[:components][:schemas] = definitions + end output 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 index fd23695e..a4af0c72 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -74,7 +74,7 @@ def document_array_param(value_type, definitions) array_items = {} if definitions[value_type[:data_type]] - array_items['$ref'] = "#/definitions/#{@parsed_param[:type]}" + array_items['$ref'] = "#/components/schemas/#{@parsed_param[:type]}" else array_items[:type] = type || @parsed_param[:schema][:type] == 'array' ? 'string' : @parsed_param[:schema][:type] end diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 31cb7f4c..6b99a0c3 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -301,7 +301,7 @@ def tag_object(route, path) def build_reference(route, value, response_model) # TODO: proof that the definition exist, if model isn't specified - reference = { '$ref' => "#/definitions/#{response_model}" } + reference = { '$ref' => "#/components/schemas/#{response_model}" } route.options[:is_array] && value[:code] < 300 ? { type: 'array', items: reference } : reference end diff --git a/spec/openapi_3/api_openapi_3_response_spec.rb b/spec/openapi_3/api_openapi_3_response_spec.rb index c724848b..b7181ccc 100644 --- a/spec/openapi_3/api_openapi_3_response_spec.rb +++ b/spec/openapi_3/api_openapi_3_response_spec.rb @@ -53,7 +53,7 @@ def app 'description' => 'This returns something', 'content' => { 'application/json' => { - 'schema' => { '$ref' => '#/definitions/UseItemResponseAsType' } + 'schema' => { '$ref' => '#/components/schemas/UseItemResponseAsType' } } } }, @@ -61,7 +61,7 @@ def app 'description' => 'NotFound', 'content' => { 'application/json' => { - 'schema' => { '$ref' => '#/definitions/ApiError' } + 'schema' => { '$ref' => '#/components/schemas/ApiError' } } } } @@ -69,7 +69,7 @@ def app 'tags' => ['nested_type'], 'operationId' => 'getNestedType' ) - expect(subject['definitions']).to eql(swagger_nested_type) + expect(subject['components']['schemas']).to eql(swagger_nested_type) end end @@ -87,7 +87,7 @@ def app 'description' => 'This returns something', 'content' => { 'application/json' => { - 'schema' => { '$ref' => '#/definitions/UseResponse' } + 'schema' => { '$ref' => '#/components/schemas/UseResponse' } } } }, @@ -95,7 +95,7 @@ def app 'description' => 'NotFound', 'content' => { 'application/json' => { - 'schema' => { '$ref' => '#/definitions/ApiError' } + 'schema' => { '$ref' => '#/components/schemas/ApiError' } } } } @@ -103,7 +103,7 @@ def app 'tags' => ['entity_response'], 'operationId' => 'getEntityResponse' ) - expect(subject['definitions']).to eql(swagger_entity_as_response_object) + expect(subject['components']['schemas']).to eql(swagger_entity_as_response_object) end end @@ -114,7 +114,6 @@ def app end specify do - fail("TODO: Fix") expect(subject['paths']['/params_given']['post']).to eql( 'description' => 'This returns something', 'requestBody' => { @@ -140,7 +139,7 @@ def app 'description' => 'NotFound', 'content' => { 'application/json' => { - 'schema' => { '$ref' => '#/definitions/ApiError' } + 'schema' => { '$ref' => '#/components/schemas/ApiError' } } } } @@ -148,7 +147,7 @@ def app 'tags' => ['params_given'], 'operationId' => 'postParamsGiven' ) - expect(subject['definitions']).to eql(swagger_params_as_response_object) + expect(subject['components']['schemas']).to eql(swagger_params_as_response_object) end end end From d3120f8b15eb9100c9b5dc9294ae416adf51b60e Mon Sep 17 00:00:00 2001 From: blake Date: Sat, 17 Nov 2018 21:47:12 +0100 Subject: [PATCH 10/55] Fix tests --- ...penapi_3_components-schemas-models_spec.rb | 36 +++++++ spec/openapi_3/params_nested_spec.rb | 100 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 spec/openapi_3/api_openapi_3_components-schemas-models_spec.rb create mode 100644 spec/openapi_3/params_nested_spec.rb 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/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 From 7f846a6845477d72f93a632f1d3518d5aa81a468 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 18 Nov 2018 13:29:32 +0100 Subject: [PATCH 11/55] Add test --- spec/openapi_3/form_params_spec.rb | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 spec/openapi_3/form_params_spec.rb diff --git a/spec/openapi_3/form_params_spec.rb b/spec/openapi_3/form_params_spec.rb new file mode 100644 index 00000000..ae7e500f --- /dev/null +++ b/spec/openapi_3/form_params_spec.rb @@ -0,0 +1,90 @@ +# 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' + puts last_response.body + 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/x-www-form-urlencoded' => { + 'schema' => { + '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 From 54de96db22940dcda2c9ad9aa7dad5f7cd3e7d48 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 25 Nov 2018 12:16:09 +0100 Subject: [PATCH 12/55] Add test --- spec/openapi_3/openapi_3_hide_param_spec.rb | 96 +++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 spec/openapi_3/openapi_3_hide_param_spec.rb 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 From aeb40a77b7aedee4702c2f27a760c2f4be675d72 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 25 Nov 2018 12:52:44 +0100 Subject: [PATCH 13/55] Fix edge cases with ranges, floats and strings --- .../openapi_3/doc_methods/parse_params.rb | 12 +- spec/openapi_3/param_values_spec.rb | 170 ++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 spec/openapi_3/param_values_spec.rb diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb index a4af0c72..bb690ab2 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -110,9 +110,17 @@ def parse_enum_or_range_values(values) when Proc parse_enum_or_range_values(values.call) if values.parameters.empty? when Range - parse_range_values(values) if values.first.is_a?(Integer) + if values.first.is_a?(Numeric) + parse_range_values(values) + else + { enum: values.to_a } + end else - { enum: values } if values + if values.respond_to? :each + { enum: values } + else + { enum: [values] } + 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..e034f3fe --- /dev/null +++ b/spec/openapi_3/param_values_spec.rb @@ -0,0 +1,170 @@ +# 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 + end + + params do + requires :letter, type: String, values: proc { %w[d e f] } + end + post :array_in_proc do + end + + params do + requires :letter, type: String, values: 'a'..'z' + end + post :range_letter do + end + + params do + requires :integer, type: Integer, values: -5..5 + end + post :range_integer do + 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['paths']["/#{request}"]['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema'] + 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 + end + + params do + requires :float, type: Float, values: -5.0..5.0 + end + post :range_float do + 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['paths']["/#{request}"]['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema'] + 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 From 1c85b58de47f52ec8db9bfd2582946cdebeca10e Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 25 Nov 2018 15:01:17 +0100 Subject: [PATCH 14/55] Add test --- spec/openapi_3/openapi_3_headers_spec.rb | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 spec/openapi_3/openapi_3_headers_spec.rb 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 From ce8c852d313ddb02adcd0bff2b15ba6f2ea610ad Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 25 Nov 2018 15:03:00 +0100 Subject: [PATCH 15/55] Add test --- spec/openapi_3/nicknamed_openapi_3_spec.rb | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 spec/openapi_3/nicknamed_openapi_3_spec.rb 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 From a7872ca0546e2737eff90cd36acd0b63017b1e53 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 25 Nov 2018 15:08:28 +0100 Subject: [PATCH 16/55] Add test --- spec/openapi_3/openapi_3_detail_spec.rb | 79 +++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 spec/openapi_3/openapi_3_detail_spec.rb 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 From f0f28163d427be86bb6d1b0e328d3ace4ec4c5b9 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 25 Nov 2018 15:16:51 +0100 Subject: [PATCH 17/55] Add test --- .../openapi_3_hide_documentation_path_spec.rb | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 spec/openapi_3/openapi_3_hide_documentation_path_spec.rb 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 From 48932a5861a4153ae14eaf7e48c1a73d77574432 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 25 Nov 2018 15:47:13 +0100 Subject: [PATCH 18/55] Add test --- .../openapi_3_request_params_fix_spec.rb | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 spec/openapi_3/openapi_3_request_params_fix_spec.rb 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..dc0f522e --- /dev/null +++ b/spec/openapi_3/openapi_3_request_params_fix_spec.rb @@ -0,0 +1,80 @@ +# 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/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 From ac5ad647b8aa207283a5900a59d9cc0d5b03cd66 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 30 Nov 2018 18:04:14 +0100 Subject: [PATCH 19/55] Better implementation of request body --- lib/grape-swagger/openapi_3/endpoint.rb | 44 ++++++++++++------- spec/openapi_3/form_params_spec.rb | 2 +- .../openapi_3_request_params_fix_spec.rb | 3 +- spec/openapi_3/param_multi_type_spec.rb | 15 ++++--- spec/openapi_3/param_type_spec.rb | 2 + spec/openapi_3/params_hash_spec.rb | 2 + spec/openapi_3/simple_mounted_api_spec.rb | 28 +++++++----- 7 files changed, 59 insertions(+), 37 deletions(-) diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 6b99a0c3..cd03e83d 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -3,7 +3,6 @@ require 'active_support' require 'active_support/core_ext/string/inflections' require 'grape-swagger/endpoint/params_parser' -require 'grape-swagger/openapi_3/doc_methods/parse_request_body' module Grape class Endpoint @@ -28,12 +27,17 @@ def swagger_object(_target_class, _request, options) object = { info: info_object(options[:info].merge(version: options[:doc_version])), openapi: '3.0.0', - authorizations: options[:authorizations], - securityDefinitions: options[:security_definitions], security: options[:security], + authorizations: options[:authorizations], servers: options[:servers].is_a?(Hash) ? [options[:servers]] : options[: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 @@ -43,7 +47,7 @@ def info_object(infos) result = { title: infos[:title] || 'API title', description: infos[:description], - termsOfServiceUrl: infos[:terms_of_service_url], + termsOfService: infos[:terms_of_service_url], contact: contact_object(infos), license: license_object(infos), version: infos[:version] @@ -123,7 +127,8 @@ def method_object(route, options, path) 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, parameters.first) + consumes = consumes_object(route, options[:format]) + method[:requestBody] = response_body_object(route, path, consumes, parameters.first) end produces = produces_object(route, options[:produces] || options[:format]) @@ -202,18 +207,23 @@ def params_object(route, options, path) parameters end - def response_body_object(_, _, parameters) - parameters = { - 'content' => parameters.group_by { |p| p[:in] }.map do |_k, v| - properties = v.map { |value| [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] }.to_h - required_values = v.select { |param| param[:required] }.map { |required| required[:name] } - result = { 'schema' => { 'type' => 'object', 'properties' => properties } } - result['schema']['required'] = required_values unless required_values.empty? - ['application/x-www-form-urlencoded', result] - end.to_h - } + def response_body_object(_, _, consumes, parameters) + body_parameters, form_parameters = parameters.partition { |p| p[:in] == 'body' } + result = consumes.map { |c| response_body_parameter_object(body_parameters, c) } - parameters + unless form_parameters.empty? + result << response_body_parameter_object(form_parameters, 'application/x-www-form-urlencoded') + end + + { content: result.to_h } + end + + def response_body_parameter_object(parameters, content_type) + properties = parameters.map { |value| [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] }.to_h + required_values = parameters.select { |param| param[:required] }.map { |required| required[:name] } + result = { schema: { type: :object, properties: properties } } + result[:schema][:required] = required_values unless required_values.empty? + [content_type, result] end def response_object(route, content_types) @@ -363,7 +373,7 @@ def expose_params_from_model(model) properties, required = parser.new(model, self).call unless properties&.any? raise GrapeSwagger::Errors::SwaggerSpec, - "Empty model #{model_name}, swagger 2.0 doesn't support empty definitions." + "Empty model #{model_name}, openapi 3.0 doesn't support empty definitions." end @definitions[model_name] = GrapeSwagger::DocMethods::BuildModelDefinition.build(model, properties, required) diff --git a/spec/openapi_3/form_params_spec.rb b/spec/openapi_3/form_params_spec.rb index ae7e500f..b43551ca 100644 --- a/spec/openapi_3/form_params_spec.rb +++ b/spec/openapi_3/form_params_spec.rb @@ -47,7 +47,6 @@ def app subject do get '/swagger_doc/items' - puts last_response.body JSON.parse(last_response.body) end @@ -68,6 +67,7 @@ def app }] expect(subject['paths']['/items/{id}']['post']['requestBody']).to eq 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, 'application/x-www-form-urlencoded' => { 'schema' => { 'properties' => { diff --git a/spec/openapi_3/openapi_3_request_params_fix_spec.rb b/spec/openapi_3/openapi_3_request_params_fix_spec.rb index dc0f522e..b144bb08 100644 --- a/spec/openapi_3/openapi_3_request_params_fix_spec.rb +++ b/spec/openapi_3/openapi_3_request_params_fix_spec.rb @@ -48,11 +48,12 @@ def app specify do expect(subject['paths']['/bookings/{id}']['put']['parameters']).to eql( [ - { 'in' => 'path', 'name' => 'id', 'schema' => { 'format' => 'int32', 'type' => 'integer' }, 'required' => true }, + { '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' } }, diff --git a/spec/openapi_3/param_multi_type_spec.rb b/spec/openapi_3/param_multi_type_spec.rb index f83f6864..39ff8cf1 100644 --- a/spec/openapi_3/param_multi_type_spec.rb +++ b/spec/openapi_3/param_multi_type_spec.rb @@ -30,13 +30,16 @@ def app end it 'reads request body type correctly' do - expect(subject['requestBody']['content']).to eq('application/x-www-form-urlencoded' => { - 'schema' => { - 'properties' => { 'another_input' => { 'type' => 'string' }, 'input' => { 'type' => 'string' } }, - 'required' => %w[input another_input], - 'type' => 'object' + 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 diff --git a/spec/openapi_3/param_type_spec.rb b/spec/openapi_3/param_type_spec.rb index 7b042c9b..308e1a53 100644 --- a/spec/openapi_3/param_type_spec.rb +++ b/spec/openapi_3/param_type_spec.rb @@ -32,6 +32,7 @@ def app 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' => { @@ -80,6 +81,7 @@ def app it 'reads param type correctly' do expect(subject).to eq 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, 'application/x-www-form-urlencoded' => { 'schema' => { 'properties' => { diff --git a/spec/openapi_3/params_hash_spec.rb b/spec/openapi_3/params_hash_spec.rb index ebf44a9f..ac68494d 100644 --- a/spec/openapi_3/params_hash_spec.rb +++ b/spec/openapi_3/params_hash_spec.rb @@ -42,6 +42,7 @@ def app 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' => { @@ -65,6 +66,7 @@ def app 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' => { diff --git a/spec/openapi_3/simple_mounted_api_spec.rb b/spec/openapi_3/simple_mounted_api_spec.rb index a8755e8c..c1cb9f4e 100644 --- a/spec/openapi_3/simple_mounted_api_spec.rb +++ b/spec/openapi_3/simple_mounted_api_spec.rb @@ -187,6 +187,7 @@ def app 'operationId' => 'postItems', 'requestBody' => { 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, 'application/x-www-form-urlencoded' => { 'schema' => { 'properties' => { @@ -314,18 +315,21 @@ def app 'post' => { 'description' => 'this takes an array of parameters', 'requestBody' => { - 'content' => { 'application/x-www-form-urlencoded' => { - 'schema' => { - 'properties' => { - 'items[]' => { - 'description' => 'array of items', - 'items' => { 'type' => 'string' }, - 'type' => 'array' - } - }, - 'type' => 'object' + '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', @@ -339,11 +343,11 @@ def app describe 'supports custom params types' do subject do get '/swagger_doc/custom.json' + raise('TODO: Fix') JSON.parse(last_response.body) end specify do - fail("TODO: Fix") expect(subject['paths']).to eq( '/custom' => { 'get' => { From a41d7128b388faced824ce76c63c2cc0752df74f Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 30 Nov 2018 18:09:27 +0100 Subject: [PATCH 20/55] Fix test --- spec/openapi_3/api_openapi_3_response_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/openapi_3/api_openapi_3_response_spec.rb b/spec/openapi_3/api_openapi_3_response_spec.rb index b7181ccc..774545b4 100644 --- a/spec/openapi_3/api_openapi_3_response_spec.rb +++ b/spec/openapi_3/api_openapi_3_response_spec.rb @@ -118,6 +118,7 @@ def app 'description' => 'This returns something', 'requestBody' => { 'content' => { + 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, 'application/x-www-form-urlencoded' => { 'schema' => { 'properties' => { From c2e6a5a682e6a67b6a984a5e9889698bba32dd90 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 30 Nov 2018 18:12:49 +0100 Subject: [PATCH 21/55] Add test --- spec/openapi_3/operation_id_api_spec.rb | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 spec/openapi_3/operation_id_api_spec.rb 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 From 47ec64e3bc743b8fc6409e8d17fc8b8bb7721011 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 30 Nov 2018 18:32:16 +0100 Subject: [PATCH 22/55] Add test --- .../openapi_3_format-content_type_spec.rb | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 spec/openapi_3/openapi_3_format-content_type_spec.rb 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 From 5d3b522aeab2bba3ed4a2dd9d48694910abdae15 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 30 Nov 2018 18:38:09 +0100 Subject: [PATCH 23/55] Add test --- .../openapi_3_ignore_defaults_spec.rb | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 spec/openapi_3/openapi_3_ignore_defaults_spec.rb 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 From d305600a6e3bcaab846d7059dbca10a22889170c Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 2 Dec 2018 19:46:09 +0100 Subject: [PATCH 24/55] fix array test --- .../openapi_3/doc_methods/parse_params.rb | 12 +- spec/openapi_3/params_array_spec.rb | 232 ++++++++++++++++++ 2 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 spec/openapi_3/params_array_spec.rb diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb index bb690ab2..bbeb6565 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -74,7 +74,7 @@ def document_array_param(value_type, definitions) array_items = {} if definitions[value_type[:data_type]] - array_items['$ref'] = "#/components/schemas/#{@parsed_param[: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 @@ -116,10 +116,12 @@ def parse_enum_or_range_values(values) { enum: values.to_a } end else - if values.respond_to? :each - { enum: values } - else - { enum: [values] } + if values + if values.respond_to? :each + { enum: values } + else + { enum: [values] } + end 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..cfc05e70 --- /dev/null +++ b/spec/openapi_3/params_array_spec.rb @@ -0,0 +1,232 @@ +# 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' + puts last_response.body + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/groups']['post']['requestBody']['content']['application/x-www-form-urlencoded']).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' + puts last_response.body + 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', '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' + puts last_response.body + JSON.parse(last_response.body) + end + + specify do + expect(subject['definitions']['postObjectAndArray']['type']).to eql 'object' + expect(subject['definitions']['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 From dcdcc385a855f87ec5be93b03cd1057d8bf8a0fe Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 11:32:08 +0100 Subject: [PATCH 25/55] Fix test --- lib/grape-swagger/openapi_3/doc_methods.rb | 4 +- .../openapi_3/doc_methods/move_params.rb | 239 ++++++++++++++++++ spec/openapi_3/params_array_spec.rb | 9 +- 3 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 lib/grape-swagger/openapi_3/doc_methods/move_params.rb diff --git a/lib/grape-swagger/openapi_3/doc_methods.rb b/lib/grape-swagger/openapi_3/doc_methods.rb index 1907edfd..b11f3bee 100644 --- a/lib/grape-swagger/openapi_3/doc_methods.rb +++ b/lib/grape-swagger/openapi_3/doc_methods.rb @@ -9,7 +9,7 @@ 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/doc_methods/move_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' @@ -56,7 +56,7 @@ def setup(options) output[:tags] = tags unless tags.empty? || paths.blank? output[:paths] = paths unless paths.blank? unless definitions.blank? - output[:components] = {} + output[:components] ||= {} output[:components][:schemas] = definitions 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..a7810ab9 --- /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 MoveParams + 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/spec/openapi_3/params_array_spec.rb b/spec/openapi_3/params_array_spec.rb index cfc05e70..55beccb4 100644 --- a/spec/openapi_3/params_array_spec.rb +++ b/spec/openapi_3/params_array_spec.rb @@ -80,7 +80,6 @@ describe 'retrieves the documentation for grouped parameters' do subject do get '/swagger_doc/groups' - puts last_response.body JSON.parse(last_response.body) end @@ -101,7 +100,6 @@ describe 'retrieves the documentation for typed group parameters' do subject do get '/swagger_doc/type_given' - puts last_response.body JSON.parse(last_response.body) end @@ -152,7 +150,7 @@ 'type' => 'string', 'description' => 'nested array of strings' }, 'array_of_integer' => { - 'type' => 'integer', 'description' => 'nested array of integers' + 'type' => 'integer', 'format' => 'int32', 'description' => 'nested array of integers' } }, 'required' => %w[array_of_string array_of_integer] @@ -163,13 +161,12 @@ describe 'documentation for simple and array parameters' do subject do get '/swagger_doc/object_and_array' - puts last_response.body JSON.parse(last_response.body) end specify do - expect(subject['definitions']['postObjectAndArray']['type']).to eql 'object' - expect(subject['definitions']['postObjectAndArray']['properties']).to eql( + 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', From 1518be3e1e016280da0c8eeecec1729657dbe191 Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 13:46:34 +0100 Subject: [PATCH 26/55] Add test --- .../openapi_3_param_type_body_nested_spec.rb | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 spec/openapi_3/openapi_3_param_type_body_nested_spec.rb 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 From 8998cbf18c68345cc3534609fcad8155c557db31 Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 14:24:31 +0100 Subject: [PATCH 27/55] Add test --- .../openapi_3_param_type_body_spec.rb | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 spec/openapi_3/openapi_3_param_type_body_spec.rb 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..02833963 --- /dev/null +++ b/spec/openapi_3/openapi_3_param_type_body_spec.rb @@ -0,0 +1,244 @@ +# 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' => { + 'properties' => { + 'WoEntitiesInBody' => { '$ref' => '#/components/schemas/postWoEntitiesInBody' } + }, + 'required' => ['WoEntitiesInBody'], + 'type' => 'object' + } + ) + 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' => { + 'properties' => { + 'WoEntitiesInBody' => { '$ref' => '#/components/schemas/putWoEntitiesInBody' } + }, + 'required' => ['WoEntitiesInBody'], + 'type' => 'object' + } + ) + 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' => { + 'properties' => { + 'WithEntitiesInBody' => { + '$ref' => '#/components/schemas/postWithEntitiesInBody' + } + }, + 'required' => ['WithEntitiesInBody'], + 'type' => 'object' + } + ) + 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' => { + 'properties' => { + 'WithEntitiesInBody' => { '$ref' => '#/components/schemas/putWithEntitiesInBody' } + }, + 'required' => ['WithEntitiesInBody'], + 'type' => 'object' + } + ) + 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' => { + 'properties' => { + 'WithEntityParam' => { '$ref' => '#/components/schemas/postWithEntityParam' } + }, + 'required' => ['WithEntityParam'], + 'type' => 'object' + } + } + end + + let(:request_body_parameters_definition) do + { + 'description' => 'put in body with entity parameter', + 'properties' => { 'data' => { '$ref' => '#/components/schemas/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']['ApiResponse']).not_to be_nil + end + + specify do + expect(subject['components']['schemas']['postWithEntityParam']).to eql(request_body_parameters_definition) + end + end +end From e1d09c17d25e74b68ace299b40a3bdaa54e1c26d Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 15:39:51 +0100 Subject: [PATCH 28/55] Add test --- spec/openapi_3/openapi_3_type-format_spec.rb | 113 +++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 spec/openapi_3/openapi_3_type-format_spec.rb 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 From 3eb21a21b8f9280860d13cd76c42d1199fdabbd2 Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 15:43:58 +0100 Subject: [PATCH 29/55] Add test --- spec/openapi_3/boolean_params_spec.rb | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 spec/openapi_3/boolean_params_spec.rb 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 From 12e7a4f042fa3d438bb7125f44d2aabfb4b74047 Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 15:45:10 +0100 Subject: [PATCH 30/55] Add test --- spec/openapi_3/deprecated_field_spec.rb | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 spec/openapi_3/deprecated_field_spec.rb 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 From d65d13e3589f124297f3e77e96d91a223293355c Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 15:59:34 +0100 Subject: [PATCH 31/55] Add test --- spec/openapi_3/errors_spec.rb | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 spec/openapi_3/errors_spec.rb 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 From bbf21b9f9be9a913a1f2bac5bc7d9adfecd14b75 Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 16:24:00 +0100 Subject: [PATCH 32/55] Add test --- spec/openapi_3/float_api_spec.rb | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 spec/openapi_3/float_api_spec.rb 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 From 5ff4d3915a222cc5564e27392b4fb853b0a5a6fc Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 16:29:17 +0100 Subject: [PATCH 33/55] Add test --- spec/openapi_3/mount_override_api_spec.rb | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 spec/openapi_3/mount_override_api_spec.rb 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 From 4c470fb173feba320c4a42ea1ca2befa1b5b0fcc Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 16:32:04 +0100 Subject: [PATCH 34/55] Add test --- .../openapi_3_body_definitions_spec.rb | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 spec/openapi_3/openapi_3_body_definitions_spec.rb 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 From f50a24ab2247d5d906994deeb21990c88149e892 Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 6 Dec 2018 16:47:20 +0100 Subject: [PATCH 35/55] Fix tests --- lib/grape-swagger/openapi_3/endpoint.rb | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index cd03e83d..738c4440 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -208,18 +208,30 @@ def params_object(route, options, path) end def response_body_object(_, _, consumes, parameters) - body_parameters, form_parameters = parameters.partition { |p| p[:in] == 'body' } - result = consumes.map { |c| response_body_parameter_object(body_parameters, c) } + 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_parameters.empty? - result << response_body_parameter_object(form_parameters, 'application/x-www-form-urlencoded') + unless form_params.empty? + result << response_body_parameter_object(form_params, 'application/x-www-form-urlencoded') + end + + unless file_params.empty? + result << response_body_parameter_object(file_params, 'application/octet-stream') end { content: result.to_h } end def response_body_parameter_object(parameters, content_type) - properties = parameters.map { |value| [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] }.to_h + properties = parameters.map do |value| + value[:schema][:type] = 'object' if value[:schema][:type] == 'json' + if value[:schema][:type] == 'file' + value[:schema][:format] = 'binary' + value[:schema][:type] = 'string' + end + [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] + end.to_h required_values = parameters.select { |param| param[:required] }.map { |required| required[:name] } result = { schema: { type: :object, properties: properties } } result[:schema][:required] = required_values unless required_values.empty? From 7a3fb3703d269522cdda50b13f754897a41c2d27 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 7 Dec 2018 16:06:42 +0100 Subject: [PATCH 36/55] Add test --- spec/openapi_3/namespaced_api_spec.rb | 121 ++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 spec/openapi_3/namespaced_api_spec.rb 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 From a89c16f7fb0efac6dc7fc48b1e7242a3d1119fd3 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 7 Dec 2018 16:59:16 +0100 Subject: [PATCH 37/55] Add test --- .../openapi_3/endpoint_versioned_path_spec.rb | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 spec/openapi_3/endpoint_versioned_path_spec.rb 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 From c0d9ee9e2c0cdf06360b367227dada3cc8f3ab7f Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 7 Dec 2018 17:02:46 +0100 Subject: [PATCH 38/55] Add test --- spec/openapi_3/namespace_tags_spec.rb | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 spec/openapi_3/namespace_tags_spec.rb diff --git a/spec/openapi_3/namespace_tags_spec.rb b/spec/openapi_3/namespace_tags_spec.rb new file mode 100644 index 00000000..04cda374 --- /dev/null +++ b/spec/openapi_3/namespace_tags_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'namespace tags check' do + include_context 'namespace example' + + before :all do + class TestApi < Grape::API + mount TheApi::NamespaceApi + 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 From 7199cafa6edfc3b2f891c3dda32f4e6399c26184 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 7 Dec 2018 17:30:06 +0100 Subject: [PATCH 39/55] Add test --- .../openapi_3_hash_and_array_spec.rb | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 spec/openapi_3/openapi_3_hash_and_array_spec.rb 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 From 1ebeb3383bcdc06977ae262586267d29e7c85704 Mon Sep 17 00:00:00 2001 From: blake Date: Tue, 11 Dec 2018 22:36:28 +0100 Subject: [PATCH 40/55] Fix file response body --- lib/grape-swagger/openapi_3/endpoint.rb | 42 ++- .../openapi_3_response_with_headers_spec.rb | 309 ++++++++++++++++++ 2 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 spec/openapi_3/openapi_3_response_with_headers_spec.rb diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 738c4440..6f27d0d3 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -216,9 +216,7 @@ def response_body_object(_, _, consumes, parameters) result << response_body_parameter_object(form_params, 'application/x-www-form-urlencoded') end - unless file_params.empty? - result << response_body_parameter_object(file_params, 'application/octet-stream') - end + result << response_body_parameter_object(file_params, 'application/octet-stream') unless file_params.empty? { content: result.to_h } end @@ -246,9 +244,17 @@ def response_object(route, content_types) value[:message] ||= '' memo[value[:code]] = { description: value[:message] } - memo[value[:code]][:headers] = value[:headers] if value[:headers] + if value[:headers] + value[:headers].each do |_, header| + header[:schema] = { type: header.delete(:type) } + end + memo[value[:code]][:headers] = value[:headers] + end - next build_file_response(memo[value[:code]]) if file_response?(value[:model]) + 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] @@ -263,19 +269,29 @@ def response_object(route, content_types) 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.map do |c| - if model - [c, { schema: ref }] - else - [c, {}] - end - end.to_h + + memo[value[:code]][:content] = content_types.map { |c| content_object(value, model, ref, c) }.to_h next unless model @definitions[response_model][:description] = description_object(route) + end + end - memo[value[:code]][:examples] = value[:examples] if value[:examples] + 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].map { |k, v| [k, { value: v }] }.to_h + end + end + + [content_type, hash] + else + [content_type, {}] 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..aeaf0f50 --- /dev/null +++ b/spec/openapi_3/openapi_3_response_with_headers_spec.rb @@ -0,0 +1,309 @@ +# 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' + puts last_response.body + 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 From b3eaa88c8a4e898997215550de43bae3c4d6137c Mon Sep 17 00:00:00 2001 From: blake Date: Mon, 17 Dec 2018 13:05:48 +0100 Subject: [PATCH 41/55] Add examples spec --- .../openapi_3_response_with_examples_spec.rb | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 spec/openapi_3/openapi_3_response_with_examples_spec.rb 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..b1545dec --- /dev/null +++ b/spec/openapi_3/openapi_3_response_with_examples_spec.rb @@ -0,0 +1,230 @@ +# 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' + puts last_response.body + 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 From b4c95fee2ffb11f4d048eee22b45b41c3ddb488e Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 27 Dec 2018 16:12:09 +0100 Subject: [PATCH 42/55] Fix host test --- lib/grape-swagger/openapi_3/endpoint.rb | 9 +++-- spec/openapi_3/host_spec.rb | 48 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 spec/openapi_3/host_spec.rb diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 6f27d0d3..2474c3e6 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -23,13 +23,18 @@ def content_types_for(target_class) # openapi 3.0 related parts # # required keys for SwaggerObject - def swagger_object(_target_class, _request, options) + 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 = options[:servers] || [{ url: "#{request.scheme}://#{url}#{base_path}" }] + servers = servers.is_a?(Hash) ? [servers] : servers + object = { info: info_object(options[:info].merge(version: options[:doc_version])), openapi: '3.0.0', security: options[:security], authorizations: options[:authorizations], - servers: options[:servers].is_a?(Hash) ? [options[:servers]] : options[:servers] + servers: servers } if options[:security_definitions] || options[:security] 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 From 230efaf0f3c2a7016661b8f08f00295886b0abbe Mon Sep 17 00:00:00 2001 From: blake Date: Thu, 27 Dec 2018 16:28:01 +0100 Subject: [PATCH 43/55] Add specs --- .../api_swagger_v2_param_type_spec.rb | 250 ++++++++++++++++++ spec/openapi_3/default_api_spec.rb | 185 +++++++++++++ .../description_not_initialized_spec.rb | 42 +++ spec/openapi_3/hide_api_spec.rb | 163 ++++++++++++ spec/openapi_3/mounted_target_class_spec.rb | 84 ++++++ spec/openapi_3/namespace_tags_prefix_spec.rb | 107 ++++++++ spec/openapi_3/openapi_3_extensions_spec.rb | 150 +++++++++++ .../openapi_3_global_configuration_spec.rb | 55 ++++ spec/openapi_3/security_requirement_spec.rb | 25 ++ 9 files changed, 1061 insertions(+) create mode 100644 spec/openapi_3/api_swagger_v2_param_type_spec.rb create mode 100644 spec/openapi_3/default_api_spec.rb create mode 100644 spec/openapi_3/description_not_initialized_spec.rb create mode 100644 spec/openapi_3/hide_api_spec.rb create mode 100644 spec/openapi_3/mounted_target_class_spec.rb create mode 100644 spec/openapi_3/namespace_tags_prefix_spec.rb create mode 100644 spec/openapi_3/openapi_3_extensions_spec.rb create mode 100644 spec/openapi_3/openapi_3_global_configuration_spec.rb create mode 100644 spec/openapi_3/security_requirement_spec.rb 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/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/description_not_initialized_spec.rb b/spec/openapi_3/description_not_initialized_spec.rb new file mode 100644 index 00000000..e1886828 --- /dev/null +++ b/spec/openapi_3/description_not_initialized_spec.rb @@ -0,0 +1,42 @@ +# 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' + puts last_response.body + 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/hide_api_spec.rb b/spec/openapi_3/hide_api_spec.rb new file mode 100644 index 00000000..5e50b164 --- /dev/null +++ b/spec/openapi_3/hide_api_spec.rb @@ -0,0 +1,163 @@ +# 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' + puts last_response.body + 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/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/openapi_3_extensions_spec.rb b/spec/openapi_3/openapi_3_extensions_spec.rb new file mode 100644 index 00000000..fd0b5705 --- /dev/null +++ b/spec/openapi_3/openapi_3_extensions_spec.rb @@ -0,0 +1,150 @@ +# 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' + puts last_response.body + 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' + puts last_response.body + 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' + puts last_response.body + 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' + puts last_response.body + 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' + puts last_response.body + 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_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/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 From 5d6bb3a2fd9e6a12a75a85e2e1fcb7344fb190e8 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 28 Dec 2018 15:58:10 +0100 Subject: [PATCH 44/55] Fix guarded endpoint spec, after a rebase --- lib/grape-swagger.rb | 55 +++-------- lib/grape-swagger/openapi_3/openapi3.rb | 36 -------- lib/grape-swagger/swagger_2/swagger2.rb | 37 -------- spec/openapi_3/guarded_endpoint_spec.rb | 117 ++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 114 deletions(-) delete mode 100644 lib/grape-swagger/openapi_3/openapi3.rb delete mode 100644 lib/grape-swagger/swagger_2/swagger2.rb create mode 100644 spec/openapi_3/guarded_endpoint_spec.rb diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index aaff9481..f9aa37d2 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -4,6 +4,7 @@ require 'grape-swagger/instance' +require 'grape-swagger/errors' require 'grape-swagger/version' require 'grape-swagger/model_parsers' @@ -16,41 +17,6 @@ def model_parsers autoload :Rake, 'grape-swagger/rake/oapi_tasks' end -def add_swagger_documentation(options = {}) - options = { target_class: self }.merge(options) - - version_for(options) - - documentation_class = if options[:openapi_version] == '3.0' - require 'grape-swagger/openapi_3/openapi3' - OpenApi.new.add_swagger_documentation(options) - else - require 'grape-swagger/swagger_2/swagger2' - Swagger.new.add_swagger_documentation(options) - end - - @target_class = options[:target_class] - - mount(documentation_class) - - @target_class.combined_routes = {} - combine_routes(@target_class, documentation_class) - - @target_class.combined_namespaces = {} - combine_namespaces(@target_class) - - @target_class.combined_namespace_routes = {} - @target_class.combined_namespace_identifiers = {} - combine_namespace_routes(@target_class.combined_namespaces) - - exclusive_route_keys = @target_class.combined_routes.keys - @target_class.combined_namespaces.keys - exclusive_route_keys.each do |key| - @target_class.combined_namespace_routes[key] = @target_class.combined_routes[key] - end - - documentation_class -end - module SwaggerRouting private @@ -153,11 +119,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) @@ -205,9 +170,17 @@ def combine_namespaces(app) combined_namespaces end - def create_documentation_class - Class.new(GrapeInstance) do - extend GrapeSwagger::DocMethods + def create_documentation_class(openapi_version) + Class.new(Grape::API) do + if openapi_version == '3.0' + require 'grape-swagger/openapi_3/endpoint' + require 'grape-swagger/openapi_3/doc_methods' + extend GrapeOpenAPI::DocMethods + else + require 'grape-swagger/swagger_2/endpoint' + require 'grape-swagger/swagger_2/doc_methods' + extend GrapeSwagger::DocMethods + end end end end diff --git a/lib/grape-swagger/openapi_3/openapi3.rb b/lib/grape-swagger/openapi_3/openapi3.rb deleted file mode 100644 index a2b29f9b..00000000 --- a/lib/grape-swagger/openapi_3/openapi3.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'grape' - -require 'grape-swagger/errors' -require 'grape-swagger/openapi_3/endpoint' -require 'grape-swagger/openapi_3/doc_methods' - -require 'grape-swagger/model_parsers' - -module GrapeSwagger - class << self - def model_parsers - @model_parsers ||= GrapeSwagger::ModelParsers.new - end - end - autoload :Rake, 'grape-swagger/rake/oapi_tasks' -end - -module Grape - class OpenApi - def add_swagger_documentation(options = {}) - documentation_class = create_documentation_class - documentation_class.setup(options) - documentation_class - end - - private - - def create_documentation_class - Class.new(Grape::API) do - extend GrapeOpenAPI::DocMethods - end - end - end -end diff --git a/lib/grape-swagger/swagger_2/swagger2.rb b/lib/grape-swagger/swagger_2/swagger2.rb deleted file mode 100644 index cbd7c00f..00000000 --- a/lib/grape-swagger/swagger_2/swagger2.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'grape' - -require 'grape-swagger/version' -require 'grape-swagger/errors' - -require 'grape-swagger/swagger_2/endpoint' -require 'grape-swagger/swagger_2/doc_methods' -require 'grape-swagger/model_parsers' - -module GrapeSwagger - class << self - def model_parsers - @model_parsers ||= GrapeSwagger::ModelParsers.new - end - end - autoload :Rake, 'grape-swagger/rake/oapi_tasks' -end - -module Grape - class Swagger - def add_swagger_documentation(options = {}) - documentation_class = create_documentation_class - documentation_class.setup(options) - documentation_class - end - - private - - def create_documentation_class - Class.new(Grape::API) do - extend GrapeSwagger::DocMethods - end - end - 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..3c4770b5 --- /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 + + Grape::API.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 From 9713a2b3bffde197270ad81f92d063e0e3dcf433 Mon Sep 17 00:00:00 2001 From: blake Date: Fri, 28 Dec 2018 16:16:13 +0100 Subject: [PATCH 45/55] Fix spec --- spec/openapi_3/simple_mounted_api_spec.rb | 58 ++++++++++++++++------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/spec/openapi_3/simple_mounted_api_spec.rb b/spec/openapi_3/simple_mounted_api_spec.rb index c1cb9f4e..b7b0fb20 100644 --- a/spec/openapi_3/simple_mounted_api_spec.rb +++ b/spec/openapi_3/simple_mounted_api_spec.rb @@ -61,7 +61,7 @@ class SimpleMountedApi < Grape::API 'custom' => { type: CustomType, description: 'array of items', is_array: true } } - get '/custom' do + post '/custom' do {} end end @@ -171,14 +171,26 @@ def app } }, '/custom' => { - 'get' => { + 'post' => { 'description' => 'this uses a custom parameter', - 'operationId' => 'getCustom', - 'responses' => { '200' => { - 'content' => { 'application/json' => {} }, - 'description' => 'this uses a custom parameter' - } }, - 'tags' => ['custom'] + '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' => { @@ -340,24 +352,38 @@ def app 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' - raise('TODO: Fix') JSON.parse(last_response.body) end specify do expect(subject['paths']).to eq( '/custom' => { - 'get' => { + 'post' => { 'description' => 'this uses a custom parameter', - 'operationId' => 'getCustom', - 'responses' => { - '200' => { 'content' => { 'application/json' => {} }, - 'description' => 'this uses a custom parameter' } - }, - 'tags' => ['custom'] + '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'] } } ) From 1f9c44910bec9aa59769abb86f1f242d04d80905 Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 30 Dec 2018 12:07:10 +0100 Subject: [PATCH 46/55] Add spec --- spec/openapi_3/openapi_3_spec.rb | 239 +++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 spec/openapi_3/openapi_3_spec.rb diff --git a/spec/openapi_3/openapi_3_spec.rb b/spec/openapi_3/openapi_3_spec.rb new file mode 100644 index 00000000..8a0dea2f --- /dev/null +++ b/spec/openapi_3/openapi_3_spec.rb @@ -0,0 +1,239 @@ +# 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' + puts last_response.body + 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 From 9b6cb2aa8c5a2949679d3ce2b323f7ca9601047e Mon Sep 17 00:00:00 2001 From: blake Date: Sun, 20 Jan 2019 18:12:36 +0100 Subject: [PATCH 47/55] Add spec --- lib/grape-swagger.rb | 21 +- lib/grape-swagger/doc_methods/extensions.rb | 12 +- lib/grape-swagger/openapi_3/doc_methods.rb | 8 + .../openapi_3/doc_methods/move_params.rb | 2 +- .../openapi_3/doc_methods/parse_params.rb | 7 +- .../doc_methods/parse_request_body.rb | 122 ---------- lib/grape-swagger/openapi_3/endpoint.rb | 30 +-- lib/grape-swagger/swagger_2/doc_methods.rb | 2 - lib/grape-swagger/swagger_2/endpoint.rb | 2 +- spec/openapi_3/guarded_endpoint_spec.rb | 2 +- spec/openapi_3/namespace_tags_spec.rb | 71 +++++- .../params_array_collection_format_spec.rb | 112 +++++++++ spec/support/model_parsers/mock_parser.rb | 227 +++++++++++++++++- 13 files changed, 463 insertions(+), 155 deletions(-) delete mode 100644 lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb create mode 100644 spec/openapi_3/params_array_collection_format_spec.rb diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index f9aa37d2..41c3e272 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -7,6 +7,10 @@ require 'grape-swagger/errors' 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 @@ -140,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 @@ -149,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 @@ -171,14 +186,10 @@ def combine_namespaces(app) end def create_documentation_class(openapi_version) - Class.new(Grape::API) do + Class.new(GrapeInstance) do if openapi_version == '3.0' - require 'grape-swagger/openapi_3/endpoint' - require 'grape-swagger/openapi_3/doc_methods' extend GrapeOpenAPI::DocMethods else - require 'grape-swagger/swagger_2/endpoint' - require 'grape-swagger/swagger_2/doc_methods' extend GrapeSwagger::DocMethods 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/openapi_3/doc_methods.rb b/lib/grape-swagger/openapi_3/doc_methods.rb index b11f3bee..eacbd31a 100644 --- a/lib/grape-swagger/openapi_3/doc_methods.rb +++ b/lib/grape-swagger/openapi_3/doc_methods.rb @@ -32,6 +32,8 @@ def setup(options) 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) @@ -41,6 +43,8 @@ def setup(options) end end + desc api_doc.delete(:desc), api_doc + instance_eval(guard) unless guard.nil? output_path_definitions = proc do |combi_routes, endpoint| @@ -70,10 +74,14 @@ def setup(options) 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 diff --git a/lib/grape-swagger/openapi_3/doc_methods/move_params.rb b/lib/grape-swagger/openapi_3/doc_methods/move_params.rb index a7810ab9..f7488298 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/move_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/move_params.rb @@ -4,7 +4,7 @@ module GrapeSwagger module DocMethods - class MoveParams + class OpenAPIMoveParams class << self attr_accessor :definitions diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb index bbeb6565..4445e626 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -2,7 +2,7 @@ module GrapeSwagger module DocMethods - class ParseParams + class OpenAPIParseParams class << self def call(param, settings, path, route, definitions) method = route.request_method @@ -44,11 +44,11 @@ def document_required(settings) def document_range_values(settings) values = settings[:values] || nil enum_or_range_values = parse_enum_or_range_values(values) - @parsed_param.merge!(enum_or_range_values) if enum_or_range_values + @parsed_param[:schema].merge!(enum_or_range_values) if enum_or_range_values end def document_default_value(settings) - @parsed_param[:default] = settings[:default] if settings[:default].present? + @parsed_param[:schema][:default] = settings[:default] if settings[:default].present? end def document_type_and_format(settings, data_type) @@ -76,6 +76,7 @@ def document_array_param(value_type, definitions) if definitions[value_type[:data_type]] array_items['$ref'] = "#/components/schemas/#{@parsed_param[:schema][:type]}" else + puts value_type.inspect 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] diff --git a/lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb deleted file mode 100644 index 2269a16c..00000000 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_request_body.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -module GrapeSwagger - module DocMethods - class ParseRequestBody - class << self - def call(param, settings, path, route, _definitions) - method = route.request_method - additional_documentation = settings.fetch(:documentation, {}) - settings.merge!(additional_documentation) - data_type = DataType.call(settings) - - value_type = settings.merge(data_type: data_type, path: path, param_name: param, method: method) - - type = param_type(value_type) - return nil if type.nil? - - # required properties - @parsed_param = { - in: type, - name: settings[:full_name] || param - } - - # optional properties - document_description(settings) - document_type_and_format(settings, data_type) - document_array_param(value_type, definitions) if value_type[:is_array] - document_default_value(settings) unless value_type[:is_array] - document_range_values(settings) unless value_type[:is_array] - document_required(settings) - - @parsed_param - end - - private - - def document_description(settings) - description = settings[:desc] || settings[:description] - @parsed_param[:description] = description if description - end - - def document_required(settings) - @parsed_param[:required] = settings[:required] || false - @parsed_param[:required] = true if @parsed_param[:in] == 'path' - end - - def document_range_values(settings) - values = settings[:values] || nil - enum_or_range_values = parse_enum_or_range_values(values) - @parsed_param.merge!(enum_or_range_values) if enum_or_range_values - end - - def document_default_value(settings) - @parsed_param[:default] = settings[:default] if settings[:default].present? - end - - def document_type_and_format(settings, data_type) - if DataType.primitive?(data_type) - data = DataType.mapping(data_type) - @parsed_param[:type], @parsed_param[:format] = data - else - @parsed_param[:type] = data_type - end - @parsed_param[: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'] = "#/definitions/#{@parsed_param[:type]}" - else - array_items[:type] = type || @parsed_param[:type] == 'array' ? 'string' : @parsed_param[: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[:type] = 'array' - @parsed_param[:collectionFormat] = collection_format if DataType.collections.include?(collection_format) - end - - def param_type(value_type) - if value_type[:path].include?("{#{value_type[:param_name]}}") - nil - elsif %w[POST PUT PATCH].include?(value_type[:method]) - DataType.request_primitive?(value_type[:data_type]) ? 'formData' : 'body' - end - 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 - parse_range_values(values) if values.first.is_a?(Integer) - else - { enum: values } if values - end - end - - def parse_range_values(values) - { minimum: values.first, maximum: values.last } - end - end - end - end -end diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 2474c3e6..c2a302f9 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -5,7 +5,7 @@ require 'grape-swagger/endpoint/params_parser' module Grape - class Endpoint + module OpenAPI3Endpoint def content_types_for(target_class) content_types = (target_class.content_types || {}).values @@ -30,10 +30,10 @@ def swagger_object(_target_class, request, options) servers = servers.is_a?(Hash) ? [servers] : servers object = { - info: info_object(options[:info].merge(version: options[:doc_version])), - openapi: '3.0.0', - security: options[:security], - authorizations: options[:authorizations], + info: info_object(options[:info].merge(version: options[:doc_version])), + openapi: '3.0.0', + security: options[:security], + authorizations: options[:authorizations], servers: servers } @@ -50,12 +50,12 @@ def swagger_object(_target_class, request, options) # 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] + 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) @@ -68,7 +68,7 @@ def info_object(infos) def license_object(infos) { name: infos.delete(:license), - url: infos.delete(:license_url) + url: infos.delete(:license_url) }.delete_if { |_, value| value.blank? } end @@ -202,11 +202,11 @@ def params_object(route, options, path) elsif value[:documentation] expose_params(value[:documentation][:type]) end - GrapeSwagger::DocMethods::ParseParams.call(param, value, path, route, @definitions) + GrapeSwagger::DocMethods::OpenAPIParseParams.call(param, value, path, route, @definitions) end - if GrapeSwagger::DocMethods::MoveParams.can_be_moved?(parameters, route.request_method) - parameters = GrapeSwagger::DocMethods::MoveParams.to_definition(path, parameters, route, @definitions) + if GrapeSwagger::DocMethods::OpenAPIMoveParams.can_be_moved?(parameters, route.request_method) + parameters = GrapeSwagger::DocMethods::OpenAPIMoveParams.to_definition(path, parameters, route, @definitions) end parameters diff --git a/lib/grape-swagger/swagger_2/doc_methods.rb b/lib/grape-swagger/swagger_2/doc_methods.rb index 7c424084..efb850e3 100644 --- a/lib/grape-swagger/swagger_2/doc_methods.rb +++ b/lib/grape-swagger/swagger_2/doc_methods.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# require 'grape-swagger/endpoint' - require 'grape-swagger/doc_methods/status_codes' require 'grape-swagger/doc_methods/produces_consumes' require 'grape-swagger/doc_methods/data_type' diff --git a/lib/grape-swagger/swagger_2/endpoint.rb b/lib/grape-swagger/swagger_2/endpoint.rb index dd93f864..452a7b6a 100644 --- a/lib/grape-swagger/swagger_2/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/ClassLength def content_types_for(target_class) content_types = (target_class.content_types || {}).values diff --git a/spec/openapi_3/guarded_endpoint_spec.rb b/spec/openapi_3/guarded_endpoint_spec.rb index 3c4770b5..4bd1f66a 100644 --- a/spec/openapi_3/guarded_endpoint_spec.rb +++ b/spec/openapi_3/guarded_endpoint_spec.rb @@ -41,7 +41,7 @@ def sample_auth(*scopes) description[:auth] = { scopes: scopes } end - Grape::API.extend self + GrapeInstance.extend self end describe 'a guarded api endpoint' do diff --git a/spec/openapi_3/namespace_tags_spec.rb b/spec/openapi_3/namespace_tags_spec.rb index 04cda374..5ff649d8 100644 --- a/spec/openapi_3/namespace_tags_spec.rb +++ b/spec/openapi_3/namespace_tags_spec.rb @@ -6,8 +6,74 @@ 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::NamespaceApi + mount TheApi::NamespaceApi2 add_swagger_documentation openapi_version: '3.0' end end @@ -19,6 +85,7 @@ def app describe 'retrieves swagger-documentation on /swagger_doc' do subject do get '/swagger_doc.json' + puts last_response.body JSON.parse(last_response.body) end @@ -44,6 +111,7 @@ def app describe 'retrieves the documentation for mounted-api' do subject do get '/swagger_doc/colorado.json' + puts last_response.body JSON.parse(last_response.body) end @@ -61,6 +129,7 @@ def app describe 'includes headers' do subject do get '/swagger_doc/thames.json' + puts last_response.body JSON.parse(last_response.body) 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..43514055 --- /dev/null +++ b/spec/openapi_3/params_array_collection_format_spec.rb @@ -0,0 +1,112 @@ +# 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' + puts last_response.body + 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' + puts last_response.body + 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' + puts last_response.body + 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' + puts last_response.body + 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/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'], From 2cbb17baeec53e302411bd02bf7801ed67729f44 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sat, 13 Jul 2024 10:07:07 +0200 Subject: [PATCH 48/55] Fix: Prevent class name collisions in specs with ApiClassDefinitionCleaner Add ApiClassDefinitionCleaner module to track and remove classes that inherit from Grape::API. This change prevents classname collisions between different specs, ensures that no remaining classes interfere with subsequent tests, and prevents ArgumentError caused by unexpected module inclusions. Details: - Created ApiClassDefinitionCleaner module with before(:all) and after(:all) hooks. - Hooks track initial and current state of Grape::API subclasses. - Identifies and removes newly defined classes after each spec run to prevent unintentional changes and conflicts. --- spec/spec_helper.rb | 1 + spec/support/api_class_definition_cleaner.rb | 52 ++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 spec/support/api_class_definition_cleaner.rb diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e236c8ca..d9073a8d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -25,6 +25,7 @@ config.include RSpec::Matchers config.mock_with :rspec config.include Rack::Test::Methods + config.include ApiClassDefinitionCleaner config.raise_errors_for_deprecations! config.order = 'random' diff --git a/spec/support/api_class_definition_cleaner.rb b/spec/support/api_class_definition_cleaner.rb new file mode 100644 index 00000000..6226dd90 --- /dev/null +++ b/spec/support/api_class_definition_cleaner.rb @@ -0,0 +1,52 @@ +# 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| + 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 From fd14ca9e03c6bffc15894c45669216cb709b59b5 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sat, 13 Jul 2024 10:28:49 +0200 Subject: [PATCH 49/55] Fix ParamsParser call from OpenAPI3 module --- lib/grape-swagger/openapi_3/endpoint.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index c2a302f9..b444ea35 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -364,7 +364,7 @@ def partition_params(route, settings) default_type(required) request_params = unless declared_params.nil? && route.headers.nil? - GrapeSwagger::Endpoint::ParamsParser.parse_request_params(required, settings) + GrapeSwagger::Endpoint::ParamsParser.parse_request_params(required, settings, self) end || {} request_params.empty? ? required : request_params From f55b906119d65fddf20844fb4aa2c93b3b59c1d0 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sat, 13 Jul 2024 10:32:42 +0200 Subject: [PATCH 50/55] Skip anonymous API class definition deletion in specs --- spec/support/api_class_definition_cleaner.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/support/api_class_definition_cleaner.rb b/spec/support/api_class_definition_cleaner.rb index 6226dd90..9771a93a 100644 --- a/spec/support/api_class_definition_cleaner.rb +++ b/spec/support/api_class_definition_cleaner.rb @@ -40,6 +40,9 @@ def self.included(config) 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) From 510457faaeabfbf7ec937568b96ed4abc81ea176 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sun, 14 Jul 2024 23:08:36 +0200 Subject: [PATCH 51/55] Cleanup a bit --- lib/grape-swagger/doc_methods/parse_params.rb | 2 + .../endpoint/info_object_builder.rb | 52 ++++++++++++++ .../openapi_3/doc_methods/parse_params.rb | 68 ++----------------- lib/grape-swagger/openapi_3/endpoint.rb | 42 ++---------- lib/grape-swagger/swagger_2/endpoint.rb | 38 +---------- 5 files changed, 64 insertions(+), 138 deletions(-) create mode 100644 lib/grape-swagger/endpoint/info_object_builder.rb 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/parse_params.rb b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb index 4445e626..a3811c1b 100644 --- a/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb +++ b/lib/grape-swagger/openapi_3/doc_methods/parse_params.rb @@ -1,56 +1,14 @@ # frozen_string_literal: true +require 'grape-swagger/doc_methods/parse_params' +require 'grape-swagger/endpoint/info_object_builder' + module GrapeSwagger module DocMethods - class OpenAPIParseParams + class OpenAPIParseParams < GrapeSwagger::DocMethods::ParseParams class << self - def call(param, settings, path, route, definitions) - method = route.request_method - additional_documentation = settings.fetch(:documentation, {}) - settings.merge!(additional_documentation) - data_type = DataType.call(settings) - - value_type = settings.merge(data_type: data_type, path: path, param_name: param, method: method) - - # required properties - @parsed_param = { - in: param_type(value_type), - name: settings[:full_name] || param - } - - # optional properties - document_description(settings) - document_type_and_format(settings, data_type) - document_array_param(value_type, definitions) if value_type[:is_array] - document_default_value(settings) unless value_type[:is_array] - document_range_values(settings) unless value_type[:is_array] - document_required(settings) - - @parsed_param - end - private - def document_description(settings) - description = settings[:desc] || settings[:description] - @parsed_param[:description] = description if description - end - - def document_required(settings) - @parsed_param[:required] = settings[:required] || false - @parsed_param[:required] = true if @parsed_param[:in] == 'path' - end - - def document_range_values(settings) - values = settings[:values] || nil - enum_or_range_values = parse_enum_or_range_values(values) - @parsed_param[:schema].merge!(enum_or_range_values) if enum_or_range_values - end - - def document_default_value(settings) - @parsed_param[:schema][:default] = settings[:default] if settings[:default].present? - end - def document_type_and_format(settings, data_type) @parsed_param[:schema] = {} if DataType.primitive?(data_type) @@ -76,7 +34,6 @@ def document_array_param(value_type, definitions) if definitions[value_type[:data_type]] array_items['$ref'] = "#/components/schemas/#{@parsed_param[:schema][:type]}" else - puts value_type.inspect 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] @@ -93,19 +50,6 @@ def document_array_param(value_type, definitions) @parsed_param[:collectionFormat] = collection_format if DataType.collections.include?(collection_format) end - def param_type(value_type) - param_type = value_type[:param_type] || value_type[:in] - if value_type[:path].include?("{#{value_type[:param_name]}}") - 'path' - elsif param_type - param_type - elsif %w[POST PUT PATCH].include?(value_type[:method]) - DataType.request_primitive?(value_type[:data_type]) ? 'formData' : 'body' - else - 'query' - end - end - def parse_enum_or_range_values(values) case values when Proc @@ -126,10 +70,6 @@ def parse_enum_or_range_values(values) end end end - - def parse_range_values(values) - { minimum: values.first, maximum: values.last } - end end end end diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index b444ea35..4fe0b8ff 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -5,7 +5,7 @@ require 'grape-swagger/endpoint/params_parser' module Grape - module OpenAPI3Endpoint + module OpenAPI3Endpoint # rubocop:disable Metrics/ModuleLength def content_types_for(target_class) content_types = (target_class.content_types || {}).values @@ -30,7 +30,7 @@ def swagger_object(_target_class, request, options) servers = servers.is_a?(Hash) ? [servers] : servers object = { - info: info_object(options[:info].merge(version: options[:doc_version])), + info: GrapeSwagger::Endpoint::InfoObjectBuilder.build(options[:info].merge(version: options[:doc_version])), openapi: '3.0.0', security: options[:security], authorizations: options[:authorizations], @@ -47,40 +47,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, target_class, options) @content_types = content_types_for(target_class) @@ -193,7 +159,7 @@ def consumes_object(route, format) GrapeSwagger::DocMethods::ProducesConsumes.call(route.settings.dig(:description, :consumes) || format) end - def params_object(route, options, path) + 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 == '' @@ -202,7 +168,7 @@ def params_object(route, options, path) elsif value[:documentation] expose_params(value[:documentation][:type]) end - GrapeSwagger::DocMethods::OpenAPIParseParams.call(param, value, path, route, @definitions) + GrapeSwagger::DocMethods::OpenAPIParseParams.call(param, value, path, route, @definitions, consumes) end if GrapeSwagger::DocMethods::OpenAPIMoveParams.can_be_moved?(parameters, route.request_method) diff --git a/lib/grape-swagger/swagger_2/endpoint.rb b/lib/grape-swagger/swagger_2/endpoint.rb index 452a7b6a..abd550c5 100644 --- a/lib/grape-swagger/swagger_2/endpoint.rb +++ b/lib/grape-swagger/swagger_2/endpoint.rb @@ -5,7 +5,7 @@ require 'grape-swagger/endpoint/params_parser' module Grape - module Swagger2Endpoint # 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 = {} From 752525abc1facff16e026375e01b721af2337e5b Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sun, 14 Jul 2024 23:12:08 +0200 Subject: [PATCH 52/55] Set the right $ref object for the request body --- lib/grape-swagger/openapi_3/endpoint.rb | 42 ++++++++++++++++--------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/lib/grape-swagger/openapi_3/endpoint.rb b/lib/grape-swagger/openapi_3/endpoint.rb index 4fe0b8ff..0cdaa848 100644 --- a/lib/grape-swagger/openapi_3/endpoint.rb +++ b/lib/grape-swagger/openapi_3/endpoint.rb @@ -26,8 +26,11 @@ def content_types_for(target_class) 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 = options[:servers] || [{ url: "#{request.scheme}://#{url}#{base_path}" }] - servers = servers.is_a?(Hash) ? [servers] : servers + 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])), @@ -93,12 +96,14 @@ def method_object(route, options, path) method[:summary] = summary_object(route) method[:description] = description_object(route) - parameters = params_object(route, options, path).partition { |p| p[:in] == 'body' || p[:in] == 'formData' } + 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) - consumes = consumes_object(route, options[:format]) method[:requestBody] = response_body_object(route, path, consumes, parameters.first) end @@ -193,17 +198,24 @@ def response_body_object(_, _, consumes, parameters) end def response_body_parameter_object(parameters, content_type) - properties = parameters.map do |value| + 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 - [value[:name], value.except(:name, :in, :required, :schema).merge(value[:schema])] - end.to_h - required_values = parameters.select { |param| param[:required] }.map { |required| required[:name] } - result = { schema: { type: :object, properties: properties } } - result[:schema][:required] = required_values unless required_values.empty? + 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 @@ -213,10 +225,12 @@ def response_object(route, content_types) codes.each_with_object({}) do |value, memo| value[:message] ||= '' - memo[value[:code]] = { description: value[:message] } + memo[value[:code]] = { + description: value[:message] + } if value[:headers] - value[:headers].each do |_, header| + value[:headers].each_value do |header| header[:schema] = { type: header.delete(:type) } end memo[value[:code]][:headers] = value[:headers] @@ -241,7 +255,7 @@ def response_object(route, content_types) ref = build_reference(route, value, response_model) - memo[value[:code]][:content] = content_types.map { |c| content_object(value, model, ref, c) }.to_h + memo[value[:code]][:content] = content_types.to_h { |c| content_object(value, model, ref, c) } next unless model @@ -256,7 +270,7 @@ def content_object(value, model, ref, content_type) if value[:examples].keys.length == 1 hash[:example] = value[:examples].values.first else - hash[:examples] = value[:examples].map { |k, v| [k, { value: v }] }.to_h + hash[:examples] = value[:examples].transform_values { |v| { value: v } } end end From dcecc68a6d777debef977fd340facbe6fdae03fe Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Sun, 14 Jul 2024 23:21:46 +0200 Subject: [PATCH 53/55] Specs fix --- spec/openapi_3/form_params_spec.rb | 33 +++++++++-------- .../openapi_3_param_type_body_spec.rb | 36 ++++--------------- spec/openapi_3/param_values_spec.rb | 10 ++++-- spec/openapi_3/params_array_spec.rb | 2 +- 4 files changed, 34 insertions(+), 47 deletions(-) diff --git a/spec/openapi_3/form_params_spec.rb b/spec/openapi_3/form_params_spec.rb index b43551ca..0a0e83e7 100644 --- a/spec/openapi_3/form_params_spec.rb +++ b/spec/openapi_3/form_params_spec.rb @@ -67,24 +67,27 @@ def app }] expect(subject['paths']['/items/{id}']['post']['requestBody']).to eq 'content' => { - 'application/json' => { 'schema' => { 'properties' => {}, 'type' => 'object' } }, - 'application/x-www-form-urlencoded' => { + 'application/json' => { 'schema' => { - 'properties' => { - 'conditions' => { - 'description' => 'conditions of item', - 'enum' => %w[one two], - 'type' => 'string' - }, - 'name' => { - 'description' => 'name of item', - 'type' => 'string' - } - }, - 'required' => ['name'], - 'type' => 'object' + '$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/openapi_3_param_type_body_spec.rb b/spec/openapi_3/openapi_3_param_type_body_spec.rb index 02833963..dbfa89c2 100644 --- a/spec/openapi_3/openapi_3_param_type_body_spec.rb +++ b/spec/openapi_3/openapi_3_param_type_body_spec.rb @@ -85,11 +85,7 @@ def app specify do expect(subject['paths']['/wo_entities/in_body']['post']['requestBody']['content']['application/json']).to eql( 'schema' => { - 'properties' => { - 'WoEntitiesInBody' => { '$ref' => '#/components/schemas/postWoEntitiesInBody' } - }, - 'required' => ['WoEntitiesInBody'], - 'type' => 'object' + '$ref' => '#/components/schemas/postWoEntitiesInBody' } ) end @@ -116,11 +112,7 @@ def app expect(subject['paths']['/wo_entities/in_body/{key}']['put']['requestBody']['content']['application/json']).to eql( 'schema' => { - 'properties' => { - 'WoEntitiesInBody' => { '$ref' => '#/components/schemas/putWoEntitiesInBody' } - }, - 'required' => ['WoEntitiesInBody'], - 'type' => 'object' + '$ref' => '#/components/schemas/putWoEntitiesInBody' } ) end @@ -147,13 +139,7 @@ def app specify do expect(subject['paths']['/with_entities/in_body']['post']['requestBody']['content']['application/json']).to eql( 'schema' => { - 'properties' => { - 'WithEntitiesInBody' => { - '$ref' => '#/components/schemas/postWithEntitiesInBody' - } - }, - 'required' => ['WithEntitiesInBody'], - 'type' => 'object' + '$ref' => '#/components/schemas/postWithEntitiesInBody' } ) end @@ -183,11 +169,7 @@ def app expect(subject['paths']['/with_entities/in_body/{id}']['put']['requestBody']['content']['application/json']).to eql( 'schema' => { - 'properties' => { - 'WithEntitiesInBody' => { '$ref' => '#/components/schemas/putWithEntitiesInBody' } - }, - 'required' => ['WithEntitiesInBody'], - 'type' => 'object' + '$ref' => '#/components/schemas/putWithEntitiesInBody' } ) end @@ -207,11 +189,7 @@ def app let(:request_parameters_definition) do { 'schema' => { - 'properties' => { - 'WithEntityParam' => { '$ref' => '#/components/schemas/postWithEntityParam' } - }, - 'required' => ['WithEntityParam'], - 'type' => 'object' + '$ref' => '#/components/schemas/postWithEntityParam' } } end @@ -219,7 +197,7 @@ def app let(:request_body_parameters_definition) do { 'description' => 'put in body with entity parameter', - 'properties' => { 'data' => { '$ref' => '#/components/schemas/ApiResponse', 'description' => 'request data' } }, + 'properties' => { 'data' => { '$ref' => '#/components/schemas/NestedModule_ApiResponse', 'description' => 'request data' } }, 'type' => 'object' } end @@ -234,7 +212,7 @@ def app end specify do - expect(subject['components']['schemas']['ApiResponse']).not_to be_nil + expect(subject['components']['schemas']['NestedModule_ApiResponse']).not_to be_nil end specify do diff --git a/spec/openapi_3/param_values_spec.rb b/spec/openapi_3/param_values_spec.rb index e034f3fe..7228ae5a 100644 --- a/spec/openapi_3/param_values_spec.rb +++ b/spec/openapi_3/param_values_spec.rb @@ -12,24 +12,28 @@ def app 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' @@ -40,7 +44,7 @@ def first_parameter_info(request) get "/swagger_doc/#{request}" expect(last_response.status).to eq 200 body = JSON.parse last_response.body - body['paths']["/#{request}"]['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema'] + body['components']['schemas']["post#{request.camelize}"] end context 'Plain array values' do @@ -116,12 +120,14 @@ def app 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' @@ -132,7 +138,7 @@ def first_parameter_info(request) get "/swagger_doc/#{request}" expect(last_response.status).to eq 200 body = JSON.parse last_response.body - body['paths']["/#{request}"]['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema'] + body['components']['schemas']["post#{request.camelize}"] end context 'Non array in proc values' do diff --git a/spec/openapi_3/params_array_spec.rb b/spec/openapi_3/params_array_spec.rb index 55beccb4..7cf1c5fc 100644 --- a/spec/openapi_3/params_array_spec.rb +++ b/spec/openapi_3/params_array_spec.rb @@ -84,7 +84,7 @@ end specify do - expect(subject['paths']['/groups']['post']['requestBody']['content']['application/x-www-form-urlencoded']).to eql( + expect(subject['paths']['/groups']['post']['requestBody']['content']['application/json']).to eql( 'schema' => { 'properties' => { "required_group#{braces}[required_param_1]" => { 'items' => { 'type' => 'string' }, 'type' => 'array' }, From 00ed99e1a9e7bfdb110ab6da0227febe777e1590 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Mon, 15 Jul 2024 00:05:20 +0200 Subject: [PATCH 54/55] Add schema validation against OpenAPI3 specification --- Gemfile | 1 + spec/spec_helper.rb | 9 +++++ .../open3_response_validation_helper.rb | 35 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 spec/support/open3_response_validation_helper.rb 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/spec/spec_helper.rb b/spec/spec_helper.rb index d9073a8d..1370f2ce 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,6 +28,15 @@ 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/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 From 5288b69184a0f9a5c76e65cfbb3db3134f448e46 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Mon, 15 Jul 2024 00:08:03 +0200 Subject: [PATCH 55/55] Getting rid of the debug noise a little bit --- ..._set_default_parameter_location_based_on_consumes_spec.rb | 1 - spec/openapi_3/description_not_initialized_spec.rb | 1 - spec/openapi_3/hide_api_spec.rb | 1 - spec/openapi_3/namespace_tags_spec.rb | 3 --- spec/openapi_3/openapi_3_extensions_spec.rb | 5 ----- spec/openapi_3/openapi_3_response_with_examples_spec.rb | 1 - spec/openapi_3/openapi_3_response_with_headers_spec.rb | 1 - spec/openapi_3/openapi_3_spec.rb | 1 - spec/openapi_3/params_array_collection_format_spec.rb | 4 ---- 9 files changed, 18 deletions(-) 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/description_not_initialized_spec.rb b/spec/openapi_3/description_not_initialized_spec.rb index e1886828..e1f373d4 100644 --- a/spec/openapi_3/description_not_initialized_spec.rb +++ b/spec/openapi_3/description_not_initialized_spec.rb @@ -30,7 +30,6 @@ def app subject do get '/swagger_doc' - puts last_response.body JSON.parse(last_response.body) end diff --git a/spec/openapi_3/hide_api_spec.rb b/spec/openapi_3/hide_api_spec.rb index 5e50b164..754f9184 100644 --- a/spec/openapi_3/hide_api_spec.rb +++ b/spec/openapi_3/hide_api_spec.rb @@ -39,7 +39,6 @@ def app subject do get '/swagger_doc.json' - puts last_response.body JSON.parse(last_response.body) end diff --git a/spec/openapi_3/namespace_tags_spec.rb b/spec/openapi_3/namespace_tags_spec.rb index 5ff649d8..a09ac0d2 100644 --- a/spec/openapi_3/namespace_tags_spec.rb +++ b/spec/openapi_3/namespace_tags_spec.rb @@ -85,7 +85,6 @@ def app describe 'retrieves swagger-documentation on /swagger_doc' do subject do get '/swagger_doc.json' - puts last_response.body JSON.parse(last_response.body) end @@ -111,7 +110,6 @@ def app describe 'retrieves the documentation for mounted-api' do subject do get '/swagger_doc/colorado.json' - puts last_response.body JSON.parse(last_response.body) end @@ -129,7 +127,6 @@ def app describe 'includes headers' do subject do get '/swagger_doc/thames.json' - puts last_response.body JSON.parse(last_response.body) end diff --git a/spec/openapi_3/openapi_3_extensions_spec.rb b/spec/openapi_3/openapi_3_extensions_spec.rb index fd0b5705..e7e63c7f 100644 --- a/spec/openapi_3/openapi_3_extensions_spec.rb +++ b/spec/openapi_3/openapi_3_extensions_spec.rb @@ -82,7 +82,6 @@ def app describe 'extension on path level' do subject do get '/swagger_doc/path_extension' - puts last_response.body JSON.parse(last_response.body) end @@ -95,7 +94,6 @@ def app describe 'extension on verb level' do subject do get '/swagger_doc/verb_extension' - puts last_response.body JSON.parse(last_response.body) end @@ -108,7 +106,6 @@ def app describe 'extension on definition level' do subject do get '/swagger_doc/definitions_extension' - puts last_response.body JSON.parse(last_response.body) end @@ -122,7 +119,6 @@ def app describe 'extension on definition level' do subject do get '/swagger_doc/multiple_definitions_extension' - puts last_response.body JSON.parse(last_response.body) end @@ -137,7 +133,6 @@ def app describe 'extension on definition level' do subject do get '/swagger_doc/non_existent_status_definitions_extension' - puts last_response.body JSON.parse(last_response.body) 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 index b1545dec..c1adc0f4 100644 --- a/spec/openapi_3/openapi_3_response_with_examples_spec.rb +++ b/spec/openapi_3/openapi_3_response_with_examples_spec.rb @@ -157,7 +157,6 @@ def app subject do get '/swagger_doc/response_failure_examples' - puts last_response.body JSON.parse(last_response.body) 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 index aeaf0f50..4e180ebf 100644 --- a/spec/openapi_3/openapi_3_response_with_headers_spec.rb +++ b/spec/openapi_3/openapi_3_response_with_headers_spec.rb @@ -188,7 +188,6 @@ def app subject do get '/swagger_doc/file_response_headers' - puts last_response.body JSON.parse(last_response.body) end diff --git a/spec/openapi_3/openapi_3_spec.rb b/spec/openapi_3/openapi_3_spec.rb index 8a0dea2f..5cc1c09e 100644 --- a/spec/openapi_3/openapi_3_spec.rb +++ b/spec/openapi_3/openapi_3_spec.rb @@ -124,7 +124,6 @@ def app describe 'whole documentation' do subject do get '/v3/swagger_doc' - puts last_response.body JSON.parse(last_response.body) end diff --git a/spec/openapi_3/params_array_collection_format_spec.rb b/spec/openapi_3/params_array_collection_format_spec.rb index 43514055..607b7bfe 100644 --- a/spec/openapi_3/params_array_collection_format_spec.rb +++ b/spec/openapi_3/params_array_collection_format_spec.rb @@ -46,7 +46,6 @@ def app describe 'documentation for array parameter in default csv collectionFormat' do subject do get '/swagger_doc/array_of_strings_without_collection_format' - puts last_response.body JSON.parse(last_response.body) end @@ -62,7 +61,6 @@ def app describe 'documentation for array parameters in multi collectionFormat set from documentation' do subject do get '/swagger_doc/array_of_strings_multi_collection_format' - puts last_response.body JSON.parse(last_response.body) end @@ -78,7 +76,6 @@ def app describe 'documentation for array parameters in brackets collectionFormat set from documentation' do subject do get '/swagger_doc/array_of_strings_brackets_collection_format' - puts last_response.body JSON.parse(last_response.body) end @@ -94,7 +91,6 @@ def app describe 'documentation for array parameters with collectionFormat set to invalid option' do subject do get '/swagger_doc/array_of_strings_invalid_collection_format' - puts last_response.body JSON.parse(last_response.body) end