diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bab3faf..f73adf52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ * [#905](https://github.com/ruby-grape/grape-swagger/pull/905): Allow Float, BigDecimal and beginless ranges in `values` to set `minimum` and `maximum`, and skip infinite bounds that cannot be serialized to JSON - [@artyomb](https://github.com/artyomb). * [#1016](https://github.com/ruby-grape/grape-swagger/pull/1016): Hide the nested parameters of a Hash parameter marked `documentation: { hidden: true }`, and pass the resolved `token_owner` to parameter-level `hidden:` procs like route-level ones (raising `TokenOwnerNotFound` if the configured method is missing) - [@numbata](https://github.com/numbata). * [#1013](https://github.com/ruby-grape/grape-swagger/pull/1013): Fix array `default` values being placed inside `items` instead of on the array parameter or property, and emit an empty array `default` - [@numbata](https://github.com/numbata). +* [#957](https://github.com/ruby-grape/grape-swagger/pull/957): Fix issue where array params are sometimes omitted when `array_use_braces` is true - [@olivier-thatch](https://github.com/olivier-thatch). +* [#957](https://github.com/ruby-grape/grape-swagger/pull/957): Document nested arrays such as `Array[Array[String]]` with one `array` level per nesting instead of flattening them to a single level - [@numbata](https://github.com/numbata). * [#1012](https://github.com/ruby-grape/grape-swagger/pull/1012): Keep the Danger PR check running in CI by pinning `git` below 6 - [@numbata](https://github.com/numbata). * [#1011](https://github.com/ruby-grape/grape-swagger/pull/1011): Preserve string length, array item and object property limits in body parameter definitions, and emit `minLength`/`maxLength` only for strings (`minProperties`/`maxProperties` for hashes) instead of any non-array type - [@numbata](https://github.com/numbata). * [#1009](https://github.com/ruby-grape/grape-swagger/pull/1009): Bump the Danger `danger-comment.yml` workflow from 0.1.0 to 0.1.1 - [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D). diff --git a/lib/grape-swagger/doc_methods/parse_params.rb b/lib/grape-swagger/doc_methods/parse_params.rb index 4350a482..9671139e 100644 --- a/lib/grape-swagger/doc_methods/parse_params.rb +++ b/lib/grape-swagger/doc_methods/parse_params.rb @@ -17,6 +17,7 @@ def call(param, settings, path, route, definitions, consumes) # rubocop:disable in: param_type(value_type, consumes), name: settings[:full_name] || param } + fix_name_of_body_array_param(value_type) # optional properties document_description(settings) @@ -35,6 +36,12 @@ def call(param, settings, path, route, definitions, consumes) # rubocop:disable private + def fix_name_of_body_array_param(value_type) + return unless @parsed_param[:in] == 'body' && value_type[:is_array] + + @parsed_param[:name] = @parsed_param[:name].delete_suffix('[]') + end + def document_description(settings) description = settings[:desc] || settings[:description] @parsed_param[:description] = description if description @@ -82,11 +89,16 @@ def document_array_param(value_type, definitions) value_type ) - @parsed_param[:items] = array_items + @parsed_param[:items] = nest_array_items(array_items, value_type[:type]) @parsed_param[:type] = 'array' @parsed_param[:collectionFormat] = collection_format if DataType.collections.include?(collection_format) end + def nest_array_items(items, declared_type) + depth = declared_type.to_s[/\A\[*/].size + (depth - 1).times.reduce(items) { |inner, _| { type: 'array', items: inner } } + end + def parse_array_item(definitions, type, value_type) array_items = {} if definitions[value_type[:data_type]] diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb index e116e608..caeeb7c1 100644 --- a/lib/grape-swagger/endpoint.rb +++ b/lib/grape-swagger/endpoint.rb @@ -188,6 +188,9 @@ def params_object(route, options, path, consumes) memo << GrapeSwagger::DocMethods::ParseParams.call(param, value, path, route, @definitions, consumes) end + # braced array names collapse onto their unbraced twins once body params are renamed + parameters.uniq! { |param| [param[:name], param[:in]] } + if GrapeSwagger::DocMethods::MoveParams.can_be_moved?(route.request_method, parameters) parameters = GrapeSwagger::DocMethods::MoveParams.to_definition(path, parameters, route, @definitions) end diff --git a/spec/swagger_v2/params_array_spec.rb b/spec/swagger_v2/params_array_spec.rb index 510309b2..119a9e8b 100644 --- a/spec/swagger_v2/params_array_spec.rb +++ b/spec/swagger_v2/params_array_spec.rb @@ -95,6 +95,90 @@ { 'declared_params' => declared(params) } end + helpers do + params :common_params do + requires :array_of_string, type: Array[String] + requires :array_of_integer, type: Array[Integer] + end + end + + params do + use :common_params + end + get '/endpoint_with_common_params' do + { 'declared_params' => declared(params) } + end + + params do + use :common_params + end + post '/endpoint_with_common_params' do + { 'declared_params' => declared(params) } + end + + params do + requires :typed_group, type: Array do + requires :id, type: Integer + optional :tags, type: Array[String] + end + end + post '/nested_group_body' do + { 'declared_params' => declared(params) } + end + + params do + requires :query_array, type: Array[String], documentation: { param_type: 'query' } + requires :body_array, type: Array[Integer] + end + post '/mixed_param_types' do + { 'declared_params' => declared(params) } + end + + params do + requires :explicit_body_array, type: Array[String], documentation: { param_type: 'body' } + requires :implicit_body_array, type: Array[Integer] + end + post '/explicit_body' do + { 'declared_params' => declared(params) } + end + + params do + requires :array_of_string, type: Array[String] + end + put '/put_array' do + { 'declared_params' => declared(params) } + end + + params do + requires :array_of_string, type: Array[String] + end + patch '/patch_array' do + { 'declared_params' => declared(params) } + end + + params do + requires :array_of_string, type: Array[String] + end + desc 'form encoded array', consumes: ['application/x-www-form-urlencoded'] + post '/form_encoded_array' do + { 'declared_params' => declared(params) } + end + + params do + requires :matrix, type: Array[Array[String]] + requires :cube, type: Array[Array[Array[Integer]]] + end + post '/nested_arrays_body' do + { 'declared_params' => declared(params) } + end + + params do + requires :matrix, type: Array[Array[String]] + end + get '/nested_arrays_query' do + { 'declared_params' => declared(params) } + end + add_swagger_documentation array_use_braces: array_use_braces end end @@ -220,6 +304,188 @@ expect(subject['paths']['/array_of_entities']['post']['parameters']).to eql(parameters) end end + + describe 'retrieves the documentation for parameters shared between GET and POST endpoints' do + subject do + get '/swagger_doc/endpoint_with_common_params' + JSON.parse(last_response.body) + end + + describe 'for non-body parameters' do + specify do + expect(subject['paths']['/endpoint_with_common_params']['get']['parameters']).to eql( + [ + { 'in' => 'query', 'name' => "array_of_string#{braces}", 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => true }, + { 'in' => 'query', 'name' => "array_of_integer#{braces}", 'type' => 'array', 'items' => { 'type' => 'integer', 'format' => 'int32' }, 'required' => true } + ] + ) + end + end + + describe 'for body parameters' do + specify do + expect(subject['paths']['/endpoint_with_common_params']['post']['parameters']).to eql( + [ + { 'in' => 'body', 'name' => 'postEndpointWithCommonParams', 'schema' => { '$ref' => '#/definitions/postEndpointWithCommonParams' }, 'required' => true } + ] + ) + expect(subject['definitions']['postEndpointWithCommonParams']).to eql( + 'type' => 'object', + 'properties' => { + 'array_of_string' => { # no braces, even if array_use_braces is true + 'type' => 'array', + 'items' => { + 'type' => 'string' + } + }, + 'array_of_integer' => { # no braces, even if array_use_braces is true + 'type' => 'array', + 'items' => { + 'type' => 'integer', + 'format' => 'int32' + } + } + }, + 'required' => %w[array_of_string array_of_integer] + ) + end + end + end + + describe 'retrieves the documentation for a group of parameters sent as body' do + subject do + get '/swagger_doc/nested_group_body' + JSON.parse(last_response.body) + end + + specify do + expect(subject['definitions']['postNestedGroupBody']).to eql( + 'type' => 'object', + 'properties' => { + 'typed_group' => { + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'integer', 'format' => 'int32' }, + 'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' } } + }, + 'required' => ['id'] + } + } + }, + 'required' => ['typed_group'] + ) + end + end + + describe 'retrieves the documentation for an array that stays a query parameter on a POST endpoint' do + subject do + get '/swagger_doc/mixed_param_types' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/mixed_param_types']['post']['parameters'].first).to eql( + 'in' => 'query', 'name' => "query_array#{braces}", 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => true + ) + expect(subject['definitions']['postMixedParamTypes']).to eql( + 'type' => 'object', + 'properties' => { 'body_array' => { 'type' => 'array', 'items' => { 'type' => 'integer', 'format' => 'int32' } } }, + 'required' => ['body_array'] + ) + end + end + + describe 'retrieves the documentation for arrays when a body parameter is declared explicitly' do + subject do + get '/swagger_doc/explicit_body' + JSON.parse(last_response.body) + end + + specify do + expect(subject['definitions']['postExplicitBody']).to eql( + 'type' => 'object', + 'properties' => { + 'explicit_body_array' => { 'type' => 'array', 'items' => { 'type' => 'string' } }, + 'implicit_body_array' => { 'type' => 'array', 'items' => { 'type' => 'integer', 'format' => 'int32' } } + }, + 'required' => %w[explicit_body_array implicit_body_array] + ) + end + end + + %w[put patch].each do |verb| + describe "retrieves the documentation for an array sent as body of a #{verb.upcase} endpoint" do + subject do + get "/swagger_doc/#{verb}_array" + JSON.parse(last_response.body) + end + + specify do + expect(subject['definitions']["#{verb}#{verb.capitalize}Array"]).to eql( + 'type' => 'object', + 'properties' => { 'array_of_string' => { 'type' => 'array', 'items' => { 'type' => 'string' } } }, + 'required' => ['array_of_string'] + ) + end + end + end + + describe 'retrieves the documentation for an array sent form encoded' do + subject do + get '/swagger_doc/form_encoded_array' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/form_encoded_array']['post']['parameters']).to eql( + [{ 'in' => 'formData', 'name' => "array_of_string#{braces}", 'type' => 'array', 'items' => { 'type' => 'string' }, 'required' => true }] + ) + end + end + + describe 'retrieves the documentation for nested arrays sent as body' do + subject do + get '/swagger_doc/nested_arrays_body' + JSON.parse(last_response.body) + end + + specify do + expect(subject['definitions']['postNestedArraysBody']).to eql( + 'type' => 'object', + 'properties' => { + 'matrix' => { 'type' => 'array', 'items' => { 'type' => 'array', 'items' => { 'type' => 'string' } } }, + 'cube' => { + 'type' => 'array', + 'items' => { + 'type' => 'array', + 'items' => { 'type' => 'array', 'items' => { 'type' => 'integer', 'format' => 'int32' } } + } + } + }, + 'required' => %w[matrix cube] + ) + end + end + + describe 'retrieves the documentation for nested arrays sent as query' do + subject do + get '/swagger_doc/nested_arrays_query' + JSON.parse(last_response.body) + end + + specify do + expect(subject['paths']['/nested_arrays_query']['get']['parameters']).to eql( + [ + { + 'in' => 'query', 'name' => "matrix#{braces}", 'type' => 'array', 'required' => true, + 'items' => { 'type' => 'array', 'items' => { 'type' => 'string' } } + } + ] + ) + end + end end end end