gitlab-grape-openapi 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +222 -4
  4. data/lib/gitlab/grape_openapi/concerns/fail_fast_annotatable.rb +23 -0
  5. data/lib/gitlab/grape_openapi/concerns/limit_resolver.rb +31 -0
  6. data/lib/gitlab/grape_openapi/concerns/regex_converter.rb +58 -0
  7. data/lib/gitlab/grape_openapi/concerns/serializable.rb +19 -0
  8. data/lib/gitlab/grape_openapi/configuration.rb +24 -0
  9. data/lib/gitlab/grape_openapi/converters/coercer_resolver.rb +74 -0
  10. data/lib/gitlab/grape_openapi/converters/entity_converter.rb +267 -0
  11. data/lib/gitlab/grape_openapi/converters/operation_converter.rb +250 -0
  12. data/lib/gitlab/grape_openapi/converters/parameter_converter.rb +252 -0
  13. data/lib/gitlab/grape_openapi/converters/path_converter.rb +152 -0
  14. data/lib/gitlab/grape_openapi/converters/request_body_converter.rb +97 -0
  15. data/lib/gitlab/grape_openapi/converters/response_converter.rb +185 -0
  16. data/lib/gitlab/grape_openapi/converters/tag_converter.rb +36 -0
  17. data/lib/gitlab/grape_openapi/converters/type_resolver.rb +75 -0
  18. data/lib/gitlab/grape_openapi/generator.rb +60 -0
  19. data/lib/gitlab/grape_openapi/models/info.rb +29 -0
  20. data/lib/gitlab/grape_openapi/models/operation.rb +47 -0
  21. data/lib/gitlab/grape_openapi/models/parameter.rb +43 -0
  22. data/lib/gitlab/grape_openapi/models/path_item.rb +26 -0
  23. data/lib/gitlab/grape_openapi/models/request_body/parameter_schema.rb +250 -0
  24. data/lib/gitlab/grape_openapi/models/request_body/parameters.rb +87 -0
  25. data/lib/gitlab/grape_openapi/models/response.rb +48 -0
  26. data/lib/gitlab/grape_openapi/models/schema.rb +61 -0
  27. data/lib/gitlab/grape_openapi/models/security_scheme.rb +130 -0
  28. data/lib/gitlab/grape_openapi/models/server.rb +31 -0
  29. data/lib/gitlab/grape_openapi/models/server_variable.rb +25 -0
  30. data/lib/gitlab/grape_openapi/models/tag.rb +44 -0
  31. data/lib/gitlab/grape_openapi/request_body_registry.rb +57 -0
  32. data/lib/gitlab/grape_openapi/schema_registry.rb +26 -0
  33. data/lib/gitlab/grape_openapi/serializers/time.rb +19 -0
  34. data/lib/gitlab/grape_openapi/tag_registry.rb +29 -0
  35. data/lib/gitlab/grape_openapi/version.rb +8 -0
  36. data/lib/gitlab-grape-openapi.rb +64 -0
  37. metadata +162 -12
  38. data/lib/gitlab/grape/openapi/version.rb +0 -9
  39. data/lib/gitlab/grape/openapi.rb +0 -21
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Converters
6
+ class TagConverter
7
+ attr_reader :api_class, :tag_registry
8
+
9
+ def initialize(api_class, tag_registry)
10
+ @api_class = api_class
11
+ @tag_registry = tag_registry
12
+ end
13
+
14
+ def convert
15
+ tags = build_tags(api_class)
16
+
17
+ tags.each do |tag|
18
+ tag_registry.register(tag)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def build_tags(api_class)
25
+ tags = api_class.routes.flat_map do |route|
26
+ route.settings.dig(:description, :tags)
27
+ end.compact
28
+
29
+ tags.map do |tag_name|
30
+ Gitlab::GrapeOpenapi::Models::Tag.new(tag_name)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Converters
6
+ class TypeResolver
7
+ TYPE_MAPPINGS = {
8
+ 'API::Validations::Types::WorkhorseFile' => 'string',
9
+ 'Array' => 'array',
10
+ 'BigDecimal' => 'number',
11
+ 'Boolean' => 'boolean',
12
+ 'date' => 'string',
13
+ 'Date' => 'string',
14
+ 'date-time' => 'string',
15
+ 'dateTime' => 'string',
16
+ 'DateTime' => 'string',
17
+ 'FalseClass' => 'boolean',
18
+ 'Grape::API::Boolean' => 'boolean',
19
+ 'Gitlab::Color' => 'string',
20
+ :int => 'integer',
21
+ 'int' => 'integer',
22
+ Integer => 'integer',
23
+ 'Integer' => 'integer',
24
+ 'File' => 'string',
25
+ Float => 'number',
26
+ 'Float' => 'number',
27
+ :hash => 'object',
28
+ 'hash' => 'object',
29
+ 'Hash' => 'object',
30
+ 'JSON' => 'object',
31
+ 'Numeric' => 'number',
32
+ String => 'string',
33
+ 'String' => 'string',
34
+ 'symbol' => 'string',
35
+ 'Symbol' => 'string',
36
+ 'text' => 'string',
37
+ 'Time' => 'string',
38
+ 'TrueClass' => 'boolean'
39
+ }.freeze
40
+
41
+ FORMAT_MAPPINGS = {
42
+ 'API::Validations::Types::WorkhorseFile' => 'binary',
43
+ 'date' => 'date',
44
+ 'Date' => 'date',
45
+ 'date-time' => 'date-time',
46
+ 'dateTime' => 'date-time',
47
+ 'DateTime' => 'date-time',
48
+ 'File' => 'binary',
49
+ 'Time' => 'date-time'
50
+ }.freeze
51
+
52
+ def self.resolve_type(type)
53
+ return TYPE_MAPPINGS[type] if TYPE_MAPPINGS[type]
54
+ return type unless type.is_a?(String)
55
+ return 'object' if type.delete_prefix('::').start_with?('API::')
56
+
57
+ type
58
+ end
59
+
60
+ def self.resolve_format(format, type)
61
+ format || FORMAT_MAPPINGS[type]
62
+ end
63
+
64
+ def self.resolve_union_member(type)
65
+ if type.start_with?('[') && type.end_with?(']')
66
+ item_type = type[1..-2]
67
+ { type: 'array', items: { type: resolve_type(item_type) || 'string' } }
68
+ else
69
+ { type: resolve_type(type) || 'string' }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ class Generator
6
+ attr_reader :tag_registry
7
+
8
+ def initialize(options = {})
9
+ @api_classes = Array(options[:api_classes]).reject do |api_class|
10
+ Gitlab::GrapeOpenapi.configuration.excluded_api_classes.include?(api_class.name)
11
+ end
12
+ @schema_registry = SchemaRegistry.new
13
+ @request_body_registry = RequestBodyRegistry.new
14
+ @tag_registry = TagRegistry.new
15
+ end
16
+
17
+ def generate
18
+ initialize_tags
19
+
20
+ {
21
+ openapi: '3.0.0',
22
+ info: Gitlab::GrapeOpenapi.configuration.info.to_h,
23
+ tags: tag_registry.tags.sort_by { |t| t.fetch(:name, '') },
24
+ servers: Gitlab::GrapeOpenapi.configuration.servers.map(&:to_h),
25
+ paths: paths,
26
+ components: {
27
+ securitySchemes: security_schemes,
28
+ schemas: schemas
29
+ },
30
+ security: security_schemes.keys.map { |s| { s => [] } }
31
+ }
32
+ end
33
+
34
+ def security_schemes
35
+ Gitlab::GrapeOpenapi.configuration.security_schemes.to_h do |scheme|
36
+ [scheme.type, scheme.to_h]
37
+ end
38
+ end
39
+
40
+ def initialize_tags
41
+ @api_classes.each do |api_class|
42
+ Converters::TagConverter.new(api_class, tag_registry).convert
43
+ end
44
+ end
45
+
46
+ def paths
47
+ all_routes = @api_classes.flat_map(&:routes)
48
+ Converters::PathConverter.convert(all_routes, @schema_registry, @request_body_registry)
49
+ end
50
+
51
+ private
52
+
53
+ def schemas
54
+ entity_schemas = @schema_registry.schemas.transform_values(&:to_h)
55
+ request_body_schemas = @request_body_registry.schemas
56
+ entity_schemas.merge(request_body_schemas)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ class Info
7
+ attr_accessor :title, :description, :terms_of_service, :version, :license
8
+
9
+ def initialize(**options)
10
+ @title = options[:title]
11
+ @description = options[:description]
12
+ @terms_of_service = options[:terms_of_service]
13
+ @version = options[:version]
14
+ @license = options[:license]
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ title: title,
20
+ description: description,
21
+ termsOfService: terms_of_service,
22
+ version: version,
23
+ license: license
24
+ }.compact
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ class Operation
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#operation-object
8
+ attr_accessor :operation_id, :description, :tags, :responses, :parameters, :request_body, :summary,
9
+ :deprecated, :hidden, :annotations
10
+
11
+ def initialize
12
+ @tags = []
13
+ @request_body = {}
14
+ @parameters = []
15
+ @deprecated = false
16
+ @annotations = nil
17
+ end
18
+
19
+ def hidden?
20
+ @hidden
21
+ end
22
+
23
+ def to_h
24
+ @parameters ||= []
25
+
26
+ o = {
27
+ operationId: operation_id,
28
+ summary: summary,
29
+ description: description,
30
+ tags: tags && tags.empty? ? nil : tags,
31
+ responses: responses
32
+ }.compact
33
+
34
+ o[:deprecated] = deprecated if deprecated
35
+ o[:parameters] = parameters.map(&:to_h) if parameters.any?
36
+ o[:requestBody] = request_body if request_body.keys.any?
37
+
38
+ annotations&.each do |k, v|
39
+ o[k] = v
40
+ end
41
+
42
+ o
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ class Parameter
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#parameter-object
8
+ attr_reader :name, :required, :in_value, :description, :options, :schema, :example
9
+ attr_accessor :style, :explode
10
+
11
+ def initialize(name, options:, schema:, in_value:)
12
+ @options = options
13
+ @name = name
14
+ # From https://spec.openapis.org/oas/v3.2.0.html#common-fixed-fields: "If the parameter
15
+ # location is 'path', this property is REQUIRED and its value MUST be true.
16
+ @required = in_value == 'path' ? true : options[:required]
17
+ @description = options[:desc]
18
+ @schema = schema
19
+ @in_value = in_value
20
+ @example = options.dig(:documentation, :example)
21
+ @default = options.dig(:documentation, :default)
22
+ @style = nil
23
+ @explode = nil
24
+ end
25
+
26
+ def to_h
27
+ result = {
28
+ name: name,
29
+ required: required,
30
+ description: description,
31
+ schema: schema,
32
+ in: in_value,
33
+ example: example
34
+ }
35
+
36
+ result[:style] = style if style
37
+ result[:explode] = explode unless explode.nil?
38
+ result.compact
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ class PathItem
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#path-item-object
8
+ attr_reader :operations
9
+
10
+ def initialize
11
+ @operations = {}
12
+ end
13
+
14
+ def add_operation(method, operation)
15
+ return if operation.hidden?
16
+
17
+ @operations[method.to_s.downcase] = operation
18
+ end
19
+
20
+ def to_h
21
+ operations.transform_values(&:to_h)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ module RequestBody
7
+ class ParameterSchema
8
+ include Converters::CoercerResolver
9
+ include Concerns::Serializable
10
+ include Concerns::LimitResolver
11
+ include Concerns::FailFastAnnotatable
12
+ include Concerns::RegexConverter
13
+
14
+ def initialize(route:, key:, param_options:)
15
+ @route = route
16
+ @key = key
17
+ @param_options = param_options
18
+ @validations = validations_for(key.to_sym)
19
+ end
20
+
21
+ def build
22
+ fail_fast = fail_fast_in_validations?(validations)
23
+ @param_options = param_options.merge(fail_fast: fail_fast) if fail_fast
24
+ built_schema = build_raw_type_schema
25
+
26
+ unless built_schema
27
+ object_type = Converters::TypeResolver.resolve_type(param_options[:type]) || 'string'
28
+ object_format = Converters::TypeResolver.resolve_format(nil, param_options[:type])
29
+ built_schema = build_resolved_schema(object_type, object_format)
30
+ end
31
+
32
+ apply_allow_blank(built_schema)
33
+ apply_limit!(built_schema, validations)
34
+ built_schema
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :route, :key, :param_options, :validations
40
+
41
+ def annotated_description
42
+ return param_options[:desc] unless param_options[:fail_fast]
43
+
44
+ annotate_fail_fast(param_options[:desc])
45
+ end
46
+
47
+ # Handles schema building for types that cannot safely be passed through TypeResolver
48
+ def build_raw_type_schema
49
+ type_str = param_options[:type].to_s
50
+ mapping = coercer_mapping_for(validations)
51
+
52
+ if mapping
53
+ # Handle coerced types(e.g., coerce_with: option used)
54
+ build_coerced_schema_with_description(mapping)
55
+ elsif type_str.start_with?('[') && type_str.exclude?(',')
56
+ # Handle array types like [String] (single type in brackets)
57
+ build_simple_array_from_bracket_notation
58
+ elsif type_str.include?('API::Validations::Types::WorkhorseFile')
59
+ # Handle file types (e.g., API::Validations::Types::WorkhorseFile)
60
+ build_file_schema
61
+ elsif type_str.start_with?('[')
62
+ # Handle union types (e.g., [String, Integer])
63
+ build_union_type_schema
64
+ end
65
+ end
66
+
67
+ # Handles schema building for types that have been resolved through TypeResolver
68
+ def build_resolved_schema(object_type, object_format)
69
+ if param_options[:values].is_a?(Range)
70
+ # Handle range values
71
+ build_range_schema(object_type)
72
+ elsif param_options[:values]
73
+ # Handle enum/values
74
+ build_enum_schema(object_type)
75
+ elsif param_options[:type] == 'Array' && param_options[:params]
76
+ # Handle array types with nested params
77
+ build_nested_array_schema
78
+ elsif object_type.include?('[')
79
+ # Handle array types (simple, like Array[String])
80
+ build_array_schema
81
+ elsif param_options[:type] == 'Hash' && param_options[:params]
82
+ # Handle Hash types with nested params
83
+ build_nested_hash_schema
84
+ else
85
+ # Build basic schema
86
+ build_basic_schema(object_type, object_format)
87
+ end
88
+ end
89
+
90
+ def build_coerced_schema_with_description(mapping)
91
+ schema = build_coerced_schema(mapping)
92
+ schema[:description] = annotated_description if param_options[:desc]
93
+ schema
94
+ end
95
+
96
+ def build_simple_array_from_bracket_notation
97
+ # Handle types like [String] or [Integer]
98
+ item_type = param_options[:type].to_s.delete('[').delete(']')
99
+ schema = { type: 'array', items: { type: Converters::TypeResolver.resolve_type(item_type) } }
100
+ schema[:description] = annotated_description if param_options[:desc]
101
+ schema
102
+ end
103
+
104
+ def build_file_schema
105
+ schema = { type: 'string', format: 'binary' }
106
+ schema[:description] = annotated_description if param_options[:desc]
107
+ schema
108
+ end
109
+
110
+ def build_union_type_schema
111
+ types = param_options[:type][1..-2].split(", ")
112
+ { oneOf: types.map { |type| Converters::TypeResolver.resolve_union_member(type) } }
113
+ end
114
+
115
+ def build_range_schema(object_type)
116
+ range = param_options[:values]
117
+ schema = { type: object_type }
118
+ schema[:minimum] = range.begin if range.begin
119
+ schema[:maximum] = range.end if range.end
120
+ if param_options[:default] && serializable?(param_options[:default])
121
+ schema[:default] = param_options[:default]
122
+ end
123
+
124
+ schema[:description] = annotated_description if param_options[:desc]
125
+ schema
126
+ end
127
+
128
+ def build_enum_schema(object_type)
129
+ schema = { type: object_type }
130
+ schema[:enum] = param_options[:values] unless param_options[:values].is_a?(Proc)
131
+ if param_options[:default] && serializable?(param_options[:default])
132
+ schema[:default] = param_options[:default]
133
+ end
134
+
135
+ schema[:description] = annotated_description if param_options[:desc]
136
+ schema
137
+ end
138
+
139
+ def build_array_schema
140
+ item_type = param_options[:type].delete('[').delete(']').downcase
141
+ schema = { type: 'array', items: { type: Converters::TypeResolver.resolve_type(item_type) } }
142
+ schema[:description] = annotated_description if param_options[:desc]
143
+ schema
144
+ end
145
+
146
+ def build_nested_array_schema
147
+ schema = { type: 'array' }
148
+ schema[:description] = annotated_description if param_options[:desc]
149
+
150
+ # Build the items schema from nested params
151
+ nested_params = param_options[:params]
152
+ if nested_params && !nested_params.empty?
153
+ properties = {}
154
+ required_params = []
155
+
156
+ nested_params.each do |nested_key, nested_options|
157
+ properties[nested_key.to_s] = self.class.new(
158
+ route: route, key: nested_key, param_options: nested_options
159
+ ).build
160
+ required_params << nested_key.to_s if nested_options[:required]
161
+ end
162
+
163
+ items_schema = {
164
+ type: 'object',
165
+ properties: properties
166
+ }
167
+ items_schema[:required] = required_params unless required_params.empty?
168
+
169
+ schema[:items] = items_schema
170
+ else
171
+ # If no nested params, default to object type
172
+ schema[:items] = { type: 'object' }
173
+ end
174
+
175
+ schema
176
+ end
177
+
178
+ def build_nested_hash_schema
179
+ schema = { type: 'object' }
180
+ schema[:description] = annotated_description if param_options[:desc]
181
+
182
+ # Build the properties schema from nested params
183
+ nested_params = param_options[:params]
184
+ if nested_params && !nested_params.empty?
185
+ properties = {}
186
+ required_params = []
187
+
188
+ nested_params.each do |nested_key, nested_options|
189
+ properties[nested_key.to_s] = self.class.new(
190
+ route: route, key: nested_key, param_options: nested_options
191
+ ).build
192
+ required_params << nested_key.to_s if nested_options[:required]
193
+ end
194
+
195
+ schema[:properties] = properties
196
+ schema[:required] = required_params unless required_params.empty?
197
+ end
198
+
199
+ schema
200
+ end
201
+
202
+ def build_basic_schema(object_type, object_format)
203
+ schema = { type: object_type }
204
+ schema[:format] = object_format if object_format
205
+ if param_options[:default] && serializable?(param_options[:default])
206
+ schema[:default] = param_options[:default]
207
+ end
208
+
209
+ schema[:description] = annotated_description if param_options[:desc]
210
+
211
+ if param_options.dig(:documentation, :example)
212
+ schema[:example] = param_options.dig(:documentation, :example)
213
+ end
214
+
215
+ # Add regex validations
216
+ validation = validations&.find do |v|
217
+ v[:validator_class] == Grape::Validations::Validators::RegexpValidator
218
+ end
219
+
220
+ if validation
221
+ pattern = regexp_to_pattern(validation[:options])
222
+ schema[:pattern] = pattern if pattern
223
+ end
224
+
225
+ schema
226
+ end
227
+
228
+ def validations_for(attribute)
229
+ route
230
+ .app
231
+ .inheritable_setting
232
+ .namespace_stackable
233
+ .new_values[:validations]
234
+ &.select { |v| v[:attributes].include?(attribute) }
235
+ end
236
+
237
+ def apply_allow_blank(schema)
238
+ if param_options[:allow_blank] == false || (param_options[:required] && param_options[:values])
239
+ schema[:minLength] = 1 if schema[:type] == 'string'
240
+ elsif schema[:oneOf]
241
+ schema[:oneOf].each { |s| s[:nullable] = true }
242
+ else
243
+ schema[:nullable] = true
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module GrapeOpenapi
5
+ module Models
6
+ module RequestBody
7
+ class Parameters
8
+ attr_reader :route, :params
9
+
10
+ def initialize(route:, params:)
11
+ @route = route
12
+ @params = params
13
+ end
14
+
15
+ def extract
16
+ body_params = params.reject do |key, _|
17
+ path_with_params.include?("{#{key}}")
18
+ end
19
+
20
+ restructure_nested_params(body_params)
21
+ end
22
+
23
+ private
24
+
25
+ def path_with_params
26
+ @path_with_params ||= route.origin
27
+ .gsub(/\(\.:format\)$/, '')
28
+ .gsub(/:\w+/) { |match| "{#{match[1..]}}" }
29
+ end
30
+
31
+ def restructure_nested_params(body_params)
32
+ # Separate root params from nested params (with bracket notation)
33
+ root_params = {}
34
+ nested_map = {}
35
+
36
+ body_params.each do |key, param_options|
37
+ key_str = key.to_s
38
+ if key_str.include?('[')
39
+ # Parse bracket notation like "assets[links][name]"
40
+ parts = parse_bracket_notation(key_str)
41
+ nested_map[parts] = param_options
42
+ else
43
+ root_params[key] = param_options
44
+ end
45
+ end
46
+
47
+ # Build nested structure
48
+ nested_map.each do |parts, param_options|
49
+ insert_nested_param(root_params, parts, param_options)
50
+ end
51
+
52
+ root_params
53
+ end
54
+
55
+ def parse_bracket_notation(key)
56
+ key.scan(/\w+/)
57
+ end
58
+
59
+ def insert_nested_param(root_params, parts, param_options)
60
+ return if parts.empty?
61
+
62
+ first = parts[0]
63
+ rest = parts[1..]
64
+
65
+ # Ensure the root param exists as a Hash/object
66
+ root_params[first] ||= { type: 'Hash', required: false }
67
+
68
+ if rest.empty?
69
+ # This is a direct property (e.g., just "config")
70
+ # Merge param_options to preserve description and other metadata
71
+ root_params[first] = root_params[first].merge(param_options)
72
+ elsif rest.length == 1
73
+ # This is the parent of the final property (e.g., "assets[links]")
74
+ root_params[first][:params] ||= {}
75
+ root_params[first][:params][rest[0]] = param_options
76
+ else
77
+ # Multiple levels remaining - recurse to handle arbitrary depth
78
+ # (e.g., "config[database][pool][max_connections]")
79
+ root_params[first][:params] ||= {}
80
+ insert_nested_param(root_params[first][:params], rest, param_options)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end