Skip to content

Commit 409d701

Browse files
authored
Fix Array[Object] documentation type parsing (#86)
Fixes incorrect Swagger schema generation when using custom array documentation syntax like `type: 'Array[Object]'`. ### Problem When using `type: 'Array[Object]'` with an entity, the Swagger schema was not correctly generated as an array type with `$ref` items. ### Solution Add `array_type?` method that detects array notations: - `is_array: true` - explicit flag (existing behavior) - `type: 'Array[Object]'` - new syntax for entity arrays ### Usage Example ```ruby class Report < Grape::Entity expose :items, using: ItemEntity, documentation: { type: 'Array[Object]', desc: 'List of items' } end ``` Generates correct schema: ```json { "type": "array", "description": "List of items", "items": { "$ref": "#/definitions/ItemEntity" } } ``` ### Other Changes - Refactor `parse_grape_entity_params` into smaller, focused methods for readability - Add nil guards to prevent potential crashes - Fix indentation in `parse_nested` method ### Backward Compatibility - `is_array: true` continues to work as before - `is_array: false` is respected when explicitly set - Bare `type: 'Array'` is not double-wrapped ### Note Tests for empty entity handling require grape-swagger >= 2.1.3 ([ruby-grape/grape-swagger#963](ruby-grape/grape-swagger#963)) and are skipped for older versions. Fixes [ruby-grape/grape-swagger#962](ruby-grape/grape-swagger#962)
1 parent 1a91d68 commit 409d701

9 files changed

Lines changed: 244 additions & 34 deletions

File tree

.rspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
--format documentation
22
--color
3+
--require "spec_helper"

.rubocop_todo.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
# Note that changes in the inspected code, or installation of new
77
# versions of RuboCop, may require this file to be generated again.
88

9-
# Offense count: 3
9+
# Offense count: 7
1010
# Configuration parameters: AllowedMethods.
1111
# AllowedMethods: enums
1212
Lint/ConstantDefinitionInBlock:
1313
Exclude:
1414
- 'spec/grape-swagger/entities/response_model_spec.rb'
15+
- 'spec/issues/962_polymorphic_entity_with_custom_documentation_spec.rb'
1516
- 'spec/support/shared_contexts/inheritance_api.rb'
1617
- 'spec/support/shared_contexts/this_api.rb'
1718

@@ -21,6 +22,11 @@ Lint/EmptyBlock:
2122
Exclude:
2223
- 'spec/grape-swagger/entity/attribute_parser_spec.rb'
2324

25+
# Offense count: 1
26+
Lint/EmptyClass:
27+
Exclude:
28+
- 'spec/support/shared_contexts/custom_type_parser.rb'
29+
2430
# Offense count: 2
2531
# This cop supports unsafe autocorrection (--autocorrect-all).
2632
# Configuration parameters: AllowedMethods.
@@ -37,7 +43,7 @@ Metrics/AbcSize:
3743
# Offense count: 2
3844
# Configuration parameters: CountComments, CountAsOne.
3945
Metrics/ClassLength:
40-
Max: 120
46+
Max: 135
4147

4248
# Offense count: 2
4349
# Configuration parameters: AllowedMethods, AllowedPatterns.
@@ -79,11 +85,12 @@ RSpec/ContextWording:
7985
- 'spec/support/shared_contexts/inheritance_api.rb'
8086
- 'spec/support/shared_contexts/this_api.rb'
8187

82-
# Offense count: 2
88+
# Offense count: 3
8389
# Configuration parameters: IgnoredMetadata.
8490
RSpec/DescribeClass:
8591
Exclude:
8692
- '**/spec/features/**/*'
93+
- '**/spec/issues/**/*'
8794
- '**/spec/requests/**/*'
8895
- '**/spec/routing/**/*'
8996
- '**/spec/system/**/*'
@@ -95,10 +102,11 @@ RSpec/DescribeClass:
95102
RSpec/ExampleLength:
96103
Max: 213
97104

98-
# Offense count: 26
105+
# Offense count: 30
99106
RSpec/LeakyConstantDeclaration:
100107
Exclude:
101108
- 'spec/grape-swagger/entities/response_model_spec.rb'
109+
- '**/spec/issues/**/*'
102110
- 'spec/support/shared_contexts/inheritance_api.rb'
103111
- 'spec/support/shared_contexts/this_api.rb'
104112

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#### Fixes
88

99
* Your contribution here.
10+
* [#86](https://github.com/ruby-grape/grape-swagger-entity/pull/86): Fix Array[Object] documentation type parsing - [@numbata](https://github.com/numbata).
1011
* [#87](https://github.com/ruby-grape/grape-swagger-entity/pull/87): Remove hidden attributes from required - [@bogdan](https://github.com/bogdan).
1112
* [#88](https://github.com/ruby-grape/grape-swagger-entity/pull/88): Update danger workflows - [@numbata](https://github.com/numbata).
1213
* [#89](https://github.com/ruby-grape/grape-swagger-entity/pull/89): Remove ruby-grape-danger - [@dblock](https://github.com/dblock).

lib/grape-swagger/entity/attribute_parser.rb

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def call(entity_options)
2020
documentation = entity_options[:documentation]
2121
return param if documentation.nil?
2222

23-
add_array_documentation(param, documentation) if documentation[:is_array]
23+
add_array_documentation(param, documentation) if array_type?(documentation)
2424

2525
add_attribute_sample(param, documentation, :default)
2626
add_attribute_sample(param, documentation, :example)
@@ -37,11 +37,26 @@ def call(entity_options)
3737
def model_from(entity_options)
3838
model = entity_options[:using] if entity_options[:using].present?
3939

40-
model ||= entity_options[:documentation][:type] if could_it_be_a_model?(entity_options[:documentation])
40+
documentation = entity_options[:documentation]
41+
model ||= documentation[:type] if could_it_be_a_model?(documentation)
4142

4243
model
4344
end
4445

46+
# Checks if documentation indicates an array type that needs items wrapping.
47+
# Returns true for:
48+
# - is_array: true (explicit flag)
49+
# - type: 'Array[Something]' (array with element type)
50+
# Returns false for:
51+
# - type: 'Array' (bare array - already an array type, no wrapping needed)
52+
def array_type?(documentation)
53+
return false if documentation.nil?
54+
return documentation[:is_array] if documentation.key?(:is_array)
55+
56+
# Only match Array[ElementType] syntax, not bare 'Array'
57+
documentation[:type].to_s.match?(/\Aarray\[.+\]\z/i)
58+
end
59+
4560
def could_it_be_a_model?(value)
4661
return false if value.nil?
4762

@@ -57,6 +72,8 @@ def ambiguous_model_type?(type)
5772
end
5873

5974
def primitive_type?(type)
75+
return false if type.nil?
76+
6077
data_type = GrapeSwagger::DocMethods::DataType.call(type)
6178
GrapeSwagger::DocMethods::DataType.request_primitive?(data_type)
6279
end
@@ -69,7 +86,7 @@ def data_type_from(entity_options)
6986

7087
documented_data_type = document_data_type(documentation, data_type)
7188

72-
if documentation[:is_array]
89+
if array_type?(documentation)
7390
{
7491
type: :array,
7592
items: documented_data_type
@@ -97,7 +114,9 @@ def document_data_type(documentation, data_type)
97114
end
98115

99116
def entity_model_type(name, entity_options)
100-
if entity_options[:documentation] && entity_options[:documentation][:is_array]
117+
documentation = entity_options[:documentation]
118+
119+
if array_type?(documentation)
101120
{
102121
'type' => 'array',
103122
'items' => {

lib/grape-swagger/entity/parser.rb

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,37 +43,57 @@ def extract_params(exposure)
4343
def parse_grape_entity_params(params, parent_model = nil)
4444
return unless params
4545

46-
parsed = params.each_with_object({}) do |(entity_name, entity_options), memo|
47-
documentation_options = entity_options.fetch(:documentation, {})
48-
in_option = documentation_options.fetch(:in, nil).to_s
49-
hidden_option = documentation_options.fetch(:hidden, nil)
50-
next if in_option == 'header' || hidden_option == true
51-
52-
entity_name = entity_name.original if entity_name.is_a?(Alias)
53-
final_entity_name = entity_options.fetch(:as, entity_name)
54-
documentation = entity_options[:documentation]
55-
56-
memo[final_entity_name] = if entity_options[:nesting]
57-
parse_nested(entity_name, entity_options, parent_model)
58-
else
59-
attribute_parser.call(entity_options)
60-
end
61-
62-
next unless documentation
63-
64-
memo[final_entity_name][:readOnly] = documentation[:read_only].to_s == 'true' if documentation[:read_only]
65-
memo[final_entity_name][:description] = documentation[:desc] if documentation[:desc]
46+
required = required_params(params)
47+
parsed_params = parse_params(params, parent_model)
48+
49+
handle_discriminator(parsed_params, required)
50+
end
51+
52+
def parse_params(params, parent_model)
53+
params.each_with_object({}) do |(entity_name, entity_options), memo|
54+
next if skip_param?(entity_options)
55+
56+
original_entity_name = entity_name.is_a?(Alias) ? entity_name.original : entity_name
57+
final_entity_name = entity_options.fetch(:as, original_entity_name)
58+
59+
memo[final_entity_name] = parse_entity_options(entity_options, original_entity_name, parent_model)
60+
add_documentation_to_memo(memo[final_entity_name], entity_options[:documentation])
61+
end
62+
end
63+
64+
def skip_param?(entity_options)
65+
documentation_options = entity_options.fetch(:documentation, {})
66+
in_option = documentation_options.fetch(:in, nil).to_s
67+
hidden_option = documentation_options.fetch(:hidden, nil)
68+
69+
in_option == 'header' || hidden_option == true
70+
end
71+
72+
def parse_entity_options(entity_options, entity_name, parent_model)
73+
if entity_options[:nesting]
74+
parse_nested(entity_name, entity_options, parent_model)
75+
else
76+
attribute_parser.call(entity_options)
6677
end
78+
end
79+
80+
def add_documentation_to_memo(memo_entry, documentation)
81+
return unless documentation
82+
83+
memo_entry[:readOnly] = documentation[:read_only].to_s == 'true' if documentation[:read_only]
84+
memo_entry[:description] = documentation[:desc] if documentation[:desc]
85+
end
6786

87+
def handle_discriminator(parsed, required)
6888
discriminator = GrapeSwagger::Entity::Helper.discriminator(model)
6989
if discriminator
70-
respond_with_all_of(parsed, params, discriminator)
90+
respond_with_all_of(parsed, required, discriminator)
7191
else
72-
[parsed, required_params(params)]
92+
[parsed, required]
7393
end
7494
end
7595

76-
def respond_with_all_of(parsed, params, discriminator)
96+
def respond_with_all_of(parsed, required, discriminator)
7797
parent_name = GrapeSwagger::Entity::Helper.model_name(model.superclass, endpoint)
7898

7999
{
@@ -83,7 +103,7 @@ def respond_with_all_of(parsed, params, discriminator)
83103
},
84104
[
85105
add_discriminator(parsed, discriminator),
86-
required_params(params).push(discriminator.attribute)
106+
required.push(discriminator.attribute)
87107
]
88108
]
89109
}
@@ -111,7 +131,7 @@ def parse_nested(entity_name, entity_options, parent_model = nil)
111131
.map(&:nested_exposures)
112132
.flatten
113133
.each_with_object({}) do |value, memo|
114-
memo[value.attribute] = value.send(:options)
134+
memo[value.attribute] = value.send(:options)
115135
end
116136

117137
properties, required = parse_grape_entity_params(params, nested_entities.last)

spec/grape-swagger/entities/response_model_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def app
102102
JSON.parse(last_response.body)['definitions']
103103
end
104104

105+
include_context 'this api'
106+
105107
before :all do
106108
module TheseApi
107109
module Entities

spec/grape-swagger/entity/attribute_parser_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,29 @@ def self.to_s
218218
it { is_expected.to include(type: :array) }
219219
it { is_expected.to include(items: { type: 'string' }) }
220220

221+
context 'when using Array[Type] syntax' do
222+
# Array[Type] syntax for primitives preserves the type string as-is.
223+
# For entity models, use the `using:` option with Array[Object] syntax.
224+
let(:entity_options) { { documentation: { type: 'Array[String]', desc: 'Colors' } } }
225+
226+
it { is_expected.to include(type: :array) }
227+
it { is_expected.to include(items: { type: 'Array[String]' }) }
228+
end
229+
230+
context 'when using lowercase array[type] syntax' do
231+
let(:entity_options) { { documentation: { type: 'array[integer]', desc: 'Numbers' } } }
232+
233+
it { is_expected.to include(type: :array) }
234+
it { is_expected.to include(items: { type: 'array[integer]' }) }
235+
end
236+
237+
context 'when is_array is explicitly false' do
238+
let(:entity_options) { { documentation: { type: 'string', desc: 'Single value', is_array: false } } }
239+
240+
it { is_expected.to include(type: 'string') }
241+
it { is_expected.not_to include(:items) }
242+
end
243+
221244
context 'when it contains example' do
222245
let(:entity_options) do
223246
{ documentation: { type: 'string', desc: 'Colors', is_array: true, example: %w[green blue] } }
@@ -262,6 +285,13 @@ def self.to_s
262285
it { is_expected.not_to include('$ref') }
263286
end
264287

288+
context 'when using bare Array type' do
289+
let(:entity_options) { { documentation: { type: 'Array', desc: 'Generic array' } } }
290+
291+
it { is_expected.to include(type: 'array') }
292+
it { is_expected.not_to include(:items) }
293+
end
294+
265295
context 'when it is exposed as a Boolean class' do
266296
let(:entity_options) do
267297
{ documentation: { type: Grape::API::Boolean, example: example_value, default: example_value } }

0 commit comments

Comments
 (0)