tina4ruby 3.13.137 → 3.13.139

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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Ultipa graph adapter — the portable core built in GQL over tina4-ultipa.
4
10
  #
data/lib/tina4/env.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
- require "digest"
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
3
7
 
4
8
  module Tina4
5
9
  # Legacy env var names that v3.12 has retired. If any of these are set in
@@ -84,11 +88,15 @@ module Tina4
84
88
  # signal for Auth.ensure_dev_secret to mint a per-machine random dev secret
85
89
  # (saved to gitignored .env.local) in dev, or to emit the actionable
86
90
  # "set TINA4_SECRET" warning in CI/prod. Parity with the Python master.
91
+ #
92
+ # TINA4_DEBUG is deliberately absent too: unset debug means OFF, and a boot
93
+ # side effect must never switch it on (ADR-0079 s3). `tina4ruby init`, an
94
+ # explicit developer action, writes TINA4_DEBUG=true into the project it
95
+ # scaffolds.
87
96
  DEFAULT_ENV = {
88
97
  "PROJECT_NAME" => "Tina4 Ruby Project",
89
98
  "TINA4_SWAGGER_VERSION" => "1.0.0",
90
99
  "TINA4_LOCALE" => "en",
91
- "TINA4_DEBUG" => "true",
92
100
  "TINA4_LOG_LEVEL" => "ALL"
93
101
  }.freeze
94
102
 
@@ -260,11 +268,11 @@ module Tina4
260
268
  File.join(root_dir, ".env")
261
269
  end
262
270
 
271
+ # A missing .env gets the neutral defaults only. It no longer mints a
272
+ # TINA4_API_KEY: an MD5 of the clock is guessable, and that key opened
273
+ # the route auth gate and the dev-admin write gate (ADR-0079 s3).
263
274
  def create_default_env(path)
264
- api_key = Digest::MD5.hexdigest(Time.now.to_s)
265
- content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
266
- content += "\nTINA4_API_KEY=\"#{api_key}\"\n"
267
- File.write(path, content)
275
+ File.write(path, DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n") + "\n")
268
276
  end
269
277
 
270
278
  # Parse a dotenv file into ENV.
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 Debug — Rich error overlay for development mode.
4
10
  #
data/lib/tina4/events.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 Events — Simple observer pattern for decoupled communication.
4
10
  #
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "net/http"
5
11
  require "uri"
12
+ require_relative "parse_json"
6
13
 
7
14
  module Tina4
8
15
  # ── Customer feedback widget — server-side plumbing ─────────────────
@@ -170,7 +177,7 @@ module Tina4
170
177
  req.body = JSON.generate(forward_body)
171
178
  resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 60) { |h| h.request(req) }
172
179
  parsed = begin
173
- JSON.parse(resp.body.to_s)
180
+ Tina4.parse_json(resp.body.to_s)
174
181
  rescue JSON::ParserError
175
182
  nil
176
183
  end
@@ -293,7 +300,7 @@ module Tina4
293
300
  input.rewind if input.respond_to?(:rewind)
294
301
  raw = input.read
295
302
  return nil if raw.nil? || raw.empty?
296
- JSON.parse(raw)
303
+ Tina4.parse_json(raw)
297
304
  rescue JSON::ParserError
298
305
  nil
299
306
  end
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  module FieldTypes
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+
9
+ require "stringio"
10
+
11
+ module Tina4
12
+ # multipart/form-data parsing, owned by Tina4 (no Rack).
13
+ #
14
+ # Replaces Rack::Request#POST, which Tina4::Request used for multipart bodies
15
+ # until rack stopped being a dependency. Field names nest exactly the way Rack
16
+ # nested them - `user[name]` becomes a hash, `tags[]` an array, `a[][x]` an
17
+ # array of hashes, a repeated plain name keeps its last value - so a Ruby app
18
+ # that posts nested forms sees the same body it always did. spec/form_parser_spec.rb
19
+ # pins every shape against values captured from Rack 3.2.7.
20
+ #
21
+ # Files are parsed in the SAME pass and keyed by field name, each name mapping
22
+ # to a LIST of descriptors so a repeated file field keeps every file
23
+ # (fileupload_contract upload-repeated-field-list).
24
+ #
25
+ # The part delimiter is CRLF + "--" + boundary, as RFC 2046 defines it, so a
26
+ # file whose bytes happen to contain "--boundary" mid-line is not cut short.
27
+ module FormParser
28
+ # A name nested deeper than this is refused rather than recursed into.
29
+ # Rack's QueryParser param_depth_limit, kept for the same reason.
30
+ PARAM_DEPTH_LIMIT = 32
31
+
32
+ # A field name that is both a value and a container (`a=1` then `a[b]=2`)
33
+ # or nests past PARAM_DEPTH_LIMIT. Rack raised here too.
34
+ class ParameterError < StandardError; end
35
+
36
+ # Marks a hash the NESTING built, as opposed to a value that happens to be a
37
+ # hash. Only these may be descended into.
38
+ class NestedParams < Hash; end
39
+ private_constant :NestedParams
40
+
41
+ module_function
42
+
43
+ # The boundary token from a multipart Content-Type, quotes removed; nil when
44
+ # there is none.
45
+ def boundary(content_type)
46
+ content_type.to_s.split(";").each do |part|
47
+ part = part.strip
48
+ next unless part.downcase.start_with?("boundary=")
49
+
50
+ value = part[9..].to_s.strip
51
+ value = value[1...-1] if value.length >= 2 && value.start_with?('"') && value.end_with?('"')
52
+ return value.empty? ? nil : value
53
+ end
54
+ nil
55
+ end
56
+
57
+ # Parse a multipart body.
58
+ #
59
+ # @return [Hash] { fields: Hash (nested, Rack-shaped), files: Hash{name => [descriptor]},
60
+ # error: ParameterError or nil }
61
+ # A descriptor is { filename:, type:, content:, size:, tempfile: } with the
62
+ # RAW client filename - Tina4::Request.save_upload sanitises it on write.
63
+ # A name conflict or over-deep nesting empties the fields and is returned
64
+ # as :error rather than raised, so one bad field name cannot lose the files.
65
+ def parse_multipart(body, content_type)
66
+ token = boundary(content_type)
67
+ fields = NestedParams.new
68
+ files = {}
69
+ return { fields: {}, files: files, error: nil } if token.nil? || body.nil? || body.empty?
70
+
71
+ pairs = []
72
+ each_part(body.b, token) do |headers, content|
73
+ name, filename = disposition_params(headers["content-disposition"].to_s)
74
+ next if name.nil? || name.empty?
75
+
76
+ if filename.nil?
77
+ pairs << [name.dup.force_encoding(Encoding::UTF_8), text_value(content, headers["content-type"])]
78
+ else
79
+ (files[name] ||= []) << {
80
+ filename: filename.dup.force_encoding(Encoding::UTF_8).scrub,
81
+ type: headers["content-type"] || "application/octet-stream",
82
+ content: content,
83
+ size: content.bytesize,
84
+ tempfile: StringIO.new(content)
85
+ }
86
+ end
87
+ end
88
+ # A name conflict voids the FIELDS only (Rack raised for the whole form);
89
+ # the files were collected independently and survive it.
90
+ error = nil
91
+ begin
92
+ pairs.each { |name, value| normalize_params(fields, name, value, 0) }
93
+ rescue ParameterError => e
94
+ error = e
95
+ fields = NestedParams.new
96
+ end
97
+ { fields: to_plain_hash(fields), files: files, error: error }
98
+ end
99
+
100
+ # Yield [headers, content] for every part. headers keys are lowercased.
101
+ def each_part(body, token)
102
+ delimiter = "--#{token}".b
103
+ position = body.index(delimiter)
104
+ return if position.nil?
105
+
106
+ position += delimiter.bytesize
107
+ separator = "\r\n#{delimiter}".b
108
+ while position < body.bytesize
109
+ # "--" straight after a delimiter closes the body.
110
+ break if body.byteslice(position, 2) == "--"
111
+
112
+ position += 2 if body.byteslice(position, 2) == "\r\n"
113
+ header_end = body.index("\r\n\r\n", position)
114
+ break if header_end.nil?
115
+
116
+ next_delimiter = body.index(separator, header_end + 4)
117
+ break if next_delimiter.nil?
118
+
119
+ yield part_headers(body.byteslice(position, header_end - position)),
120
+ body.byteslice(header_end + 4, next_delimiter - header_end - 4)
121
+ position = next_delimiter + separator.bytesize
122
+ end
123
+ end
124
+
125
+ def part_headers(block)
126
+ headers = {}
127
+ block.split("\r\n").each do |line|
128
+ name, value = line.split(":", 2)
129
+ next if value.nil?
130
+
131
+ headers[name.strip.downcase] = value.strip.force_encoding(Encoding::UTF_8).scrub
132
+ end
133
+ headers
134
+ end
135
+
136
+ # name= and filename= from a Content-Disposition value, by PARAMETER (not by
137
+ # a substring match, which found "name=" inside "filename=").
138
+ def disposition_params(disposition)
139
+ params = {}
140
+ disposition.scan(/;\s*([A-Za-z0-9!#$%&'*+.^_`|~-]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;]*)/) do |key, raw|
141
+ value = raw.strip
142
+ value = value[1...-1].gsub(/\\(.)/, '\1') if value.start_with?('"') && value.end_with?('"') && value.length >= 2
143
+ params[key.downcase] ||= value
144
+ end
145
+ [params["name"], params["filename"]]
146
+ end
147
+
148
+ # A text field is UTF-8 unless the part says `text/plain; charset=...`
149
+ # (Rack's rule). An unknown charset falls back to binary, never raises.
150
+ def text_value(content, part_type)
151
+ encoding = Encoding::UTF_8
152
+ if part_type
153
+ type, *parameters = part_type.split(";").map(&:strip)
154
+ if type.casecmp?("text/plain")
155
+ parameters.each do |parameter|
156
+ key, value = parameter.split("=", 2)
157
+ next unless key&.casecmp?("charset") && value
158
+
159
+ encoding = begin
160
+ Encoding.find(value.delete('"'))
161
+ rescue ArgumentError
162
+ Encoding::BINARY
163
+ end
164
+ end
165
+ end
166
+ end
167
+ content.dup.force_encoding(encoding)
168
+ end
169
+
170
+ # Rack::QueryParser#normalize_params, ported. Returns params.
171
+ def normalize_params(params, name, value, depth)
172
+ raise ParameterError, "field name nests deeper than #{PARAM_DEPTH_LIMIT} levels" if depth >= PARAM_DEPTH_LIMIT
173
+
174
+ if depth.zero?
175
+ start = name.index("[", 1)
176
+ key = start ? name[0, start] : name
177
+ after = start ? name[start..] : ""
178
+ elsif name.start_with?("[]")
179
+ key = "[]"
180
+ after = name[2..]
181
+ elsif name.start_with?("[") && (close = name.index("]", 1))
182
+ key = name[1, close - 1]
183
+ after = name[(close + 1)..]
184
+ else
185
+ key = name
186
+ after = ""
187
+ end
188
+ return if key.empty?
189
+
190
+ if after.empty?
191
+ return [value] if key == "[]" && !depth.zero?
192
+
193
+ params[key] = value
194
+ elsif after == "["
195
+ params[name] = value
196
+ elsif after == "[]"
197
+ params[key] ||= []
198
+ raise ParameterError, "expected Array for `#{key}'" unless params[key].is_a?(Array)
199
+
200
+ params[key] << value
201
+ elsif after.start_with?("[]")
202
+ child_key = after[3, after.length - 4] if after[2] == "[" && after.end_with?("]")
203
+ child_key = after[2..] if child_key.nil? || child_key.empty? || child_key.include?("[") || child_key.include?("]")
204
+ params[key] ||= []
205
+ raise ParameterError, "expected Array for `#{key}'" unless params[key].is_a?(Array)
206
+
207
+ last = params[key].last
208
+ if last.is_a?(NestedParams) && !nested_key?(last, child_key)
209
+ normalize_params(last, child_key, value, depth + 1)
210
+ else
211
+ params[key] << normalize_params(NestedParams.new, child_key, value, depth + 1)
212
+ end
213
+ else
214
+ params[key] ||= NestedParams.new
215
+ raise ParameterError, "expected Hash for `#{key}'" unless params[key].is_a?(NestedParams)
216
+
217
+ params[key] = normalize_params(params[key], after, value, depth + 1)
218
+ end
219
+ params
220
+ end
221
+
222
+ def nested_key?(hash, key)
223
+ return false if key.include?("[]")
224
+
225
+ key.split(/[\[\]]+/).reject(&:empty?).reduce(hash) do |level, part|
226
+ return false unless level.is_a?(NestedParams) && level.key?(part)
227
+
228
+ level[part]
229
+ end
230
+ true
231
+ end
232
+
233
+ def to_plain_hash(value)
234
+ case value
235
+ when Hash then value.each_with_object({}) { |(key, child), plain| plain[key] = to_plain_hash(child) }
236
+ when Array then value.map { |child| to_plain_hash(child) }
237
+ else value
238
+ end
239
+ end
240
+ end
241
+ end