trane 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.
@@ -0,0 +1,489 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= h(service[:name]) %> API Documentation</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #fafafa;
12
+ --sidebar-bg: #1e293b;
13
+ --sidebar-text: #e2e8f0;
14
+ --sidebar-active: #38bdf8;
15
+ --card-bg: #ffffff;
16
+ --border: #e2e8f0;
17
+ --text: #1e293b;
18
+ --text-muted: #64748b;
19
+ --code-bg: #f1f5f9;
20
+ }
21
+
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
24
+ color: var(--text);
25
+ background: var(--bg);
26
+ line-height: 1.6;
27
+ }
28
+
29
+ .layout {
30
+ display: grid;
31
+ grid-template-columns: 260px 1fr;
32
+ min-height: 100vh;
33
+ }
34
+
35
+ /* Sidebar */
36
+ .sidebar {
37
+ background: var(--sidebar-bg);
38
+ color: var(--sidebar-text);
39
+ padding: 24px 0;
40
+ position: sticky;
41
+ top: 0;
42
+ height: 100vh;
43
+ overflow-y: auto;
44
+ }
45
+
46
+ .sidebar h2 {
47
+ padding: 0 20px;
48
+ font-size: 18px;
49
+ margin-bottom: 4px;
50
+ }
51
+
52
+ .sidebar h3 {
53
+ padding: 12px 20px 6px;
54
+ font-size: 11px;
55
+ text-transform: uppercase;
56
+ letter-spacing: 1px;
57
+ color: var(--text-muted);
58
+ }
59
+
60
+ .sidebar a {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 8px;
64
+ padding: 6px 20px;
65
+ color: var(--sidebar-text);
66
+ text-decoration: none;
67
+ font-size: 14px;
68
+ transition: background 0.15s;
69
+ }
70
+
71
+ .sidebar a:hover {
72
+ background: rgba(255,255,255,0.08);
73
+ }
74
+
75
+ .sidebar .method-badge {
76
+ font-size: 10px;
77
+ font-weight: 700;
78
+ padding: 1px 6px;
79
+ border-radius: 3px;
80
+ color: #fff;
81
+ min-width: 46px;
82
+ text-align: center;
83
+ flex-shrink: 0;
84
+ }
85
+
86
+ /* Main content */
87
+ .main {
88
+ padding: 32px 48px;
89
+ max-width: 960px;
90
+ }
91
+
92
+ .main > h1 {
93
+ font-size: 28px;
94
+ margin-bottom: 8px;
95
+ }
96
+
97
+ .main > .subtitle {
98
+ color: var(--text-muted);
99
+ margin-bottom: 32px;
100
+ font-size: 15px;
101
+ }
102
+
103
+ .section-title {
104
+ font-size: 22px;
105
+ margin: 40px 0 16px;
106
+ padding-bottom: 8px;
107
+ border-bottom: 2px solid var(--border);
108
+ }
109
+
110
+ /* Operation cards */
111
+ .op-card {
112
+ background: var(--card-bg);
113
+ border: 1px solid var(--border);
114
+ border-radius: 8px;
115
+ margin-bottom: 16px;
116
+ overflow: hidden;
117
+ }
118
+
119
+ .op-header {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 12px;
123
+ padding: 16px 20px;
124
+ border-bottom: 1px solid var(--border);
125
+ }
126
+
127
+ .op-header .method-badge {
128
+ font-size: 12px;
129
+ font-weight: 700;
130
+ padding: 4px 10px;
131
+ border-radius: 4px;
132
+ color: #fff;
133
+ min-width: 56px;
134
+ text-align: center;
135
+ }
136
+
137
+ .op-header .path {
138
+ font-family: "SFMono-Regular", Consolas, monospace;
139
+ font-size: 14px;
140
+ font-weight: 600;
141
+ }
142
+
143
+ .op-header .op-id {
144
+ font-family: "SFMono-Regular", Consolas, monospace;
145
+ font-size: 12px;
146
+ color: var(--text-muted);
147
+ background: var(--code-bg);
148
+ padding: 2px 8px;
149
+ border-radius: 4px;
150
+ font-weight: 500;
151
+ }
152
+
153
+ .op-header .summary {
154
+ color: var(--text-muted);
155
+ font-size: 14px;
156
+ margin-left: auto;
157
+ }
158
+
159
+ details {
160
+ border-top: 1px solid var(--border);
161
+ }
162
+
163
+ details:first-of-type {
164
+ border-top: none;
165
+ }
166
+
167
+ summary {
168
+ padding: 12px 20px;
169
+ cursor: pointer;
170
+ font-weight: 600;
171
+ font-size: 13px;
172
+ text-transform: uppercase;
173
+ letter-spacing: 0.5px;
174
+ color: var(--text-muted);
175
+ user-select: none;
176
+ }
177
+
178
+ summary:hover {
179
+ background: var(--code-bg);
180
+ }
181
+
182
+ .detail-content {
183
+ padding: 0 20px 16px;
184
+ }
185
+
186
+ /* Tables */
187
+ table {
188
+ width: 100%;
189
+ border-collapse: collapse;
190
+ font-size: 14px;
191
+ }
192
+
193
+ th {
194
+ text-align: left;
195
+ padding: 8px 12px;
196
+ background: var(--code-bg);
197
+ font-size: 12px;
198
+ text-transform: uppercase;
199
+ letter-spacing: 0.5px;
200
+ color: var(--text-muted);
201
+ }
202
+
203
+ td {
204
+ padding: 8px 12px;
205
+ border-top: 1px solid var(--border);
206
+ }
207
+
208
+ code {
209
+ font-family: "SFMono-Regular", Consolas, monospace;
210
+ font-size: 13px;
211
+ }
212
+
213
+ .type-link {
214
+ color: var(--sidebar-active);
215
+ text-decoration: none;
216
+ font-family: "SFMono-Regular", Consolas, monospace;
217
+ font-size: 13px;
218
+ }
219
+
220
+ .type-link:hover { text-decoration: underline; }
221
+
222
+ .type {
223
+ font-family: "SFMono-Regular", Consolas, monospace;
224
+ font-size: 13px;
225
+ color: var(--text-muted);
226
+ }
227
+
228
+ .badge {
229
+ font-size: 10px;
230
+ padding: 2px 6px;
231
+ border-radius: 3px;
232
+ font-weight: 600;
233
+ vertical-align: middle;
234
+ }
235
+
236
+ .badge.extra {
237
+ background: #dbeafe;
238
+ color: #1d4ed8;
239
+ }
240
+
241
+ .badge.format {
242
+ background: #fef3c7;
243
+ color: #92400e;
244
+ }
245
+
246
+ .badge.required {
247
+ background: #fee2e2;
248
+ color: #991b1b;
249
+ }
250
+
251
+ .badge.optional {
252
+ background: #f1f5f9;
253
+ color: var(--text-muted);
254
+ }
255
+
256
+ .enum-values {
257
+ font-size: 11px;
258
+ font-family: "SFMono-Regular", Consolas, monospace;
259
+ color: var(--text-muted);
260
+ margin-top: 3px;
261
+ word-break: break-word;
262
+ }
263
+
264
+ /* Representation cards */
265
+ .rep-card {
266
+ background: var(--card-bg);
267
+ border: 1px solid var(--border);
268
+ border-radius: 8px;
269
+ margin-bottom: 16px;
270
+ overflow: hidden;
271
+ }
272
+
273
+ .rep-header {
274
+ padding: 14px 20px;
275
+ font-weight: 600;
276
+ font-size: 16px;
277
+ border-bottom: 1px solid var(--border);
278
+ }
279
+
280
+ .rep-body {
281
+ padding: 0 20px 12px;
282
+ }
283
+
284
+ /* Error table */
285
+ .error-table {
286
+ background: var(--card-bg);
287
+ border: 1px solid var(--border);
288
+ border-radius: 8px;
289
+ overflow: hidden;
290
+ }
291
+
292
+ .error-table table { margin: 0; }
293
+
294
+ .status-badge {
295
+ display: inline-block;
296
+ padding: 2px 8px;
297
+ border-radius: 4px;
298
+ font-weight: 600;
299
+ font-size: 12px;
300
+ font-family: "SFMono-Regular", Consolas, monospace;
301
+ }
302
+
303
+ .status-4xx { background: #fef3c7; color: #92400e; }
304
+ .status-5xx { background: #fee2e2; color: #991b1b; }
305
+
306
+ /* Responsive */
307
+ @media (max-width: 768px) {
308
+ .layout { grid-template-columns: 1fr; }
309
+ .sidebar {
310
+ position: static;
311
+ height: auto;
312
+ padding: 16px 0;
313
+ }
314
+ .main { padding: 24px 16px; }
315
+ .op-header { flex-wrap: wrap; }
316
+ .op-header .summary { margin-left: 0; width: 100%; margin-top: 4px; }
317
+ }
318
+ </style>
319
+ </head>
320
+ <body>
321
+ <div class="layout">
322
+ <!-- Sidebar -->
323
+ <nav class="sidebar">
324
+ <h2><%= h(service[:name]) %></h2>
325
+
326
+ <h3>Operations</h3>
327
+ <% operations.each do |op| -%>
328
+ <a href="#op-<%= h(op[:id]) %>">
329
+ <span class="method-badge" style="background: <%= method_color(op[:method]) %>"><%= h(op[:method]) %></span>
330
+ <%= h(op[:id]) %>
331
+ </a>
332
+ <% end -%>
333
+
334
+ <% if representations.any? -%>
335
+ <h3>Representations</h3>
336
+ <% representations.each do |rep| -%>
337
+ <a href="#rep-<%= h(rep[:name]) %>"><%= h(rep[:name]) %></a>
338
+ <% end -%>
339
+ <% end -%>
340
+
341
+ <% if errors.any? -%>
342
+ <h3>Errors</h3>
343
+ <a href="#errors">Error Catalog</a>
344
+ <% end -%>
345
+ </nav>
346
+
347
+ <!-- Main Content -->
348
+ <main class="main">
349
+ <h1><%= h(service[:name]) %> API</h1>
350
+ <p class="subtitle">Generated by Trane</p>
351
+
352
+ <!-- Operations -->
353
+ <h2 class="section-title" id="operations">Operations</h2>
354
+
355
+ <% operations.each do |op| -%>
356
+ <div class="op-card" id="op-<%= h(op[:id]) %>">
357
+ <div class="op-header">
358
+ <span class="method-badge" style="background: <%= method_color(op[:method]) %>"><%= h(op[:method]) %></span>
359
+ <span class="path"><%= h(op[:path]) %></span>
360
+ <code class="op-id"><%= h(op[:id]) %></code>
361
+ <span class="summary"><%= h(op[:summary]) %></span>
362
+ </div>
363
+
364
+ <% if op[:request] -%>
365
+ <details open>
366
+ <summary>Request</summary>
367
+ <div class="detail-content">
368
+ <% if op[:request][:params] -%>
369
+ <table>
370
+ <thead><tr><th>Parameter</th><th>Type</th><th>Location</th><th>Required</th></tr></thead>
371
+ <tbody>
372
+ <% op[:request][:params].each do |p| -%>
373
+ <tr>
374
+ <td><code><%= h(p[:name]) %></code></td>
375
+ <td>
376
+ <span class="type"><%= h(p[:type]) %></span>
377
+ <% if p[:enum] -%>
378
+ <div class="enum-values">enum: <%= p[:enum].map { |v| h(v) }.join(", ") %></div>
379
+ <% end -%>
380
+ </td>
381
+ <td><%= h(p[:location]) %></td>
382
+ <td><%= p[:required] ? '<span class="badge required">required</span>' : '<span class="badge optional">optional</span>' %></td>
383
+ </tr>
384
+ <% end -%>
385
+ </tbody>
386
+ </table>
387
+ <% end -%>
388
+ <% if op[:request][:body] -%>
389
+ <h4 style="margin: 12px 0 8px; font-size: 13px; color: var(--text-muted);">Body</h4>
390
+ <table>
391
+ <thead><tr><th>Field</th><th>Type</th><th>Required</th></tr></thead>
392
+ <tbody>
393
+ <% op[:request][:body].each do |f| -%>
394
+ <%= render_field_row(f, include_required: true) %>
395
+ <% end -%>
396
+ </tbody>
397
+ </table>
398
+ <% end -%>
399
+ </div>
400
+ </details>
401
+ <% end -%>
402
+
403
+ <% if op[:responses] -%>
404
+ <details open>
405
+ <summary>Response</summary>
406
+ <div class="detail-content">
407
+ <% op[:responses].each do |resp| -%>
408
+ <p style="margin-bottom: 8px;"><span class="status-badge status-<%= resp[:status] / 100 %>xx"><%= resp[:status] %></span></p>
409
+ <table>
410
+ <thead><tr><th>Field</th><th>Type</th></tr></thead>
411
+ <tbody>
412
+ <% resp[:fields].each do |f| -%>
413
+ <%= render_field_row(f) %>
414
+ <% end -%>
415
+ </tbody>
416
+ </table>
417
+ <% end -%>
418
+ </div>
419
+ </details>
420
+ <% end -%>
421
+
422
+ <% if op[:errors] -%>
423
+ <details>
424
+ <summary>Errors</summary>
425
+ <div class="detail-content">
426
+ <table>
427
+ <thead><tr><th>Error Key</th><th>Status</th><th>Description</th></tr></thead>
428
+ <tbody>
429
+ <% op[:errors].each do |err_key| -%>
430
+ <% err_def = errors.find { |e| e[:key] == err_key } -%>
431
+ <tr>
432
+ <td><code><%= h(err_key) %></code></td>
433
+ <td><span class="status-badge status-<%= err_def ? err_def[:status_code] / 100 : 5 %>xx"><%= err_def ? err_def[:status_code] : "—" %></span></td>
434
+ <td><%= err_def ? h(err_def[:description]) : "—" %></td>
435
+ </tr>
436
+ <% end -%>
437
+ </tbody>
438
+ </table>
439
+ </div>
440
+ </details>
441
+ <% end -%>
442
+ </div>
443
+ <% end -%>
444
+
445
+ <!-- Representations -->
446
+ <% if representations.any? -%>
447
+ <h2 class="section-title" id="representations">Representations</h2>
448
+
449
+ <% representations.each do |rep| -%>
450
+ <div class="rep-card" id="rep-<%= h(rep[:name]) %>">
451
+ <div class="rep-header"><%= h(rep[:name]) %></div>
452
+ <div class="rep-body">
453
+ <table>
454
+ <thead><tr><th>Field</th><th>Type</th></tr></thead>
455
+ <tbody>
456
+ <% rep[:fields].each do |f| -%>
457
+ <%= render_field_row(f) %>
458
+ <% end -%>
459
+ </tbody>
460
+ </table>
461
+ </div>
462
+ </div>
463
+ <% end -%>
464
+ <% end -%>
465
+
466
+ <!-- Errors -->
467
+ <% if errors.any? -%>
468
+ <h2 class="section-title" id="errors">Error Catalog</h2>
469
+
470
+ <div class="error-table">
471
+ <table>
472
+ <thead><tr><th>Key</th><th>Status</th><th>Description</th></tr></thead>
473
+ <tbody>
474
+ <% errors.each do |err| -%>
475
+ <tr>
476
+ <td><code><%= h(err[:key]) %></code></td>
477
+ <td><span class="status-badge status-<%= err[:status_code] / 100 %>xx"><%= err[:status_code] %></span></td>
478
+ <td><%= h(err[:description]) %></td>
479
+ </tr>
480
+ <% end -%>
481
+ </tbody>
482
+ </table>
483
+ </div>
484
+ <% end -%>
485
+
486
+ </main>
487
+ </div>
488
+ </body>
489
+ </html>
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trane/routing_extension"
4
+ require "trane/docs/app"
5
+
6
+ module Trane
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Trane
9
+
10
+ # Tell Zeitwerk to ignore all contracts paths in the host application.
11
+ # Files there are DSL declarations (Trane.operation, Trane.representation,
12
+ # Trane.errors) that do not define Ruby constants. Without this ignore,
13
+ # Rails' eager_load in production would crash with
14
+ # "expected file ... to define constant ..." — see the "Autoloading note"
15
+ # in docs/wiki/Configuration.md.
16
+ #
17
+ # Hosts override the paths via `config.trane.contracts_paths = [...]` in
18
+ # `config/application.rb` (NOT in config/initializers/trane.rb — too late).
19
+ initializer "trane.ignore_autoload_paths", before: :set_autoload_paths do |app|
20
+ raw_paths = (app.config.respond_to?(:trane) &&
21
+ app.config.trane.respond_to?(:contracts_paths) &&
22
+ app.config.trane.contracts_paths) ||
23
+ Trane::Configuration::DEFAULT_CONTRACTS_PATHS
24
+
25
+ Trane.configuration._set_contracts_paths!(raw_paths)
26
+
27
+ raw_paths.each do |entry|
28
+ pn = entry.is_a?(Pathname) ? entry : Pathname.new(entry)
29
+ abs = pn.absolute? ? pn : app.root.join(pn)
30
+ next unless abs.exist?
31
+
32
+ begin
33
+ abs_str = abs.realpath.to_s
34
+ Rails.autoloaders.main.ignore(abs_str) if Rails.autoloaders.main
35
+ Rails.autoloaders.once.ignore(abs_str) if Rails.autoloaders.once
36
+ rescue Errno::ENOENT
37
+ next
38
+ end
39
+ end
40
+ end
41
+
42
+ # Prepends Trane::RoutingExtension onto ActionDispatch::Routing::Mapper so
43
+ # the `contract:` keyword is available in every `routes.draw` block, with
44
+ # no wrapper required. `before: :add_routing_paths` guarantees the prepend
45
+ # is active before Rails draws any route. `Module#prepend` is idempotent,
46
+ # so re-running this initializer (e.g. multiple anonymous Rails::Application
47
+ # instances in specs) does not duplicate the entry in ancestors.
48
+ initializer "trane.prepend_routing_extension", before: :add_routing_paths do
49
+ ActionDispatch::Routing::Mapper.prepend(Trane::RoutingExtension)
50
+ end
51
+
52
+ # Freeze Trane::Configuration after the host's initializers have run.
53
+ # Prevents post-boot mutation in multi-threaded servers (Puma, Falcon).
54
+ # Hosts must do all config in config/initializers/trane.rb.
55
+ #
56
+ # Ordering: this runs in the Engine batch (after: :load_config_initializers),
57
+ # which completes before Rails' Finisher batch. The Finisher's
58
+ # :set_routes_reloader_hook triggers route drawing — meaning
59
+ # Configuration is always frozen by the time host routes are drawn.
60
+ initializer "trane.freeze_configuration", after: :load_config_initializers do
61
+ Trane::Configuration.instance.freeze!
62
+ end
63
+
64
+ # Cross-check every drawn route's `_trane_operation` against the
65
+ # registry so a typo that survives Validation A (a well-formed
66
+ # `contract:` hash pointing at a nonexistent operation) fails at boot
67
+ # instead of at request time.
68
+ #
69
+ # Ordering: routes are NOT guaranteed drawn by `to_prepare` or
70
+ # `after_initialize` — they are only drawn (eagerly) once the Finisher
71
+ # reaches `set_routes_reloader_hook`. Running `after:` that hook is the
72
+ # earliest point where `app.routes.routes` is reliably populated, and
73
+ # only when `config.eager_load` is true (otherwise route drawing is
74
+ # lazy and deferred to first request/`reload_routes_unless_loaded`,
75
+ # which `trane:check` triggers explicitly post-boot).
76
+ initializer "trane.validate_route_contracts", after: :set_routes_reloader_hook do |app|
77
+ next unless app.config.eager_load
78
+
79
+ Trane::RouteValidator.validate!(app.routes.routes, Trane.registry)
80
+ end
81
+
82
+ # Auto-load contract definition files and validate on each prepare.
83
+ # Each of the three steps (registry reload, boot validation, docs
84
+ # precompute) is wrapped to add actionable context to any failure
85
+ # while preserving the original exception via `cause:`.
86
+ #
87
+ # NOTE: This block and the freeze_configuration initializer above
88
+ # intentionally route through the Trane::Registry / Trane::Configuration
89
+ # module-level shims (rather than Trane.registry directly) to preserve
90
+ # mock compatibility in engine_to_prepare_error_context_spec.rb.
91
+ # If you ever change them to bypass the shim, update that spec accordingly.
92
+ #
93
+ # Loading order lives in Trane::ContractLoader (shared with the
94
+ # integration test harness).
95
+ config.to_prepare do
96
+ if defined?(Rails.root) && Rails.root
97
+ last_loaded_file = nil
98
+
99
+ begin
100
+ Trane::Registry.replace! do |_builder|
101
+ Trane::ContractLoader.each_file(Rails.root, Trane.configuration.contracts_paths) do |file|
102
+ last_loaded_file = file
103
+ load file
104
+ end
105
+ end
106
+ rescue Trane::Error
107
+ raise
108
+ rescue ScriptError, StandardError => e
109
+ raise Trane::Error,
110
+ "Trane: failed to load contract files in to_prepare. " \
111
+ "Last attempted file: #{last_loaded_file || '(none — pre-load setup)'}. " \
112
+ "Original: #{e.class}: #{e.message}",
113
+ cause: e
114
+ end
115
+
116
+ if Rails.application.config.eager_load
117
+ begin
118
+ Trane::Registry.validate!
119
+ rescue Trane::Error
120
+ raise
121
+ rescue StandardError => e
122
+ raise Trane::Error,
123
+ "Trane: BootValidator raised an unexpected error type during to_prepare. " \
124
+ "Original: #{e.class}: #{e.message}",
125
+ cause: e
126
+ end
127
+ end
128
+
129
+ # The host routes are NOT drawn during to_prepare: it runs before the
130
+ # Finisher's set_routes_reloader_hook in EVERY environment (confirmed
131
+ # empirically — the route set is empty here even under eager_load).
132
+ # Precomputing the docs now would build a snapshot from an empty route
133
+ # set, so every operation would fall back to method "GET" with an empty
134
+ # path. Instead we invalidate; the first post-boot read (inside a
135
+ # request, with the routes drawn) computes the correct snapshot lazily
136
+ # via Cache.ensure_snapshot. In development to_prepare also runs on each
137
+ # reload, so this keeps the cache fresh after contract/route changes.
138
+ Trane::Docs::Cache.invalidate!
139
+ else
140
+ Trane::Registry.reset!
141
+ end
142
+ end
143
+
144
+ rake_tasks do
145
+ load File.expand_path("../tasks/trane.rake", __dir__)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trane
4
+ ErrorDefinition = Data.define(:key, :status_code, :description) do
5
+ def initialize(key:, status_code:, description: nil)
6
+ status_int = status_code.to_i
7
+ unless Trane::Types::HTTP_STATUS_RANGE.cover?(status_int)
8
+ raise ArgumentError,
9
+ "ErrorDefinition #{key.inspect} status_code #{status_code.inspect} is not a valid HTTP status code"
10
+ end
11
+ super(key: key.to_s, status_code: status_int, description: description&.to_s)
12
+ end
13
+ end
14
+
15
+ # Builder for `Trane.errors do ... end`
16
+ class ErrorsBuilder
17
+ attr_reader :definitions
18
+
19
+ def initialize
20
+ @definitions = []
21
+ end
22
+
23
+ # Accepts a Symbol, String (short name or FQDN), or Class.
24
+ # When a Class is given, Class#name is used as the key.
25
+ def error(key, status_code:, description: nil)
26
+ key_str = key.is_a?(Class) ? key.name : key.to_s
27
+ @definitions << ErrorDefinition.new(key: key_str, status_code: status_code, description: description)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Trane
6
+ module ExtraAttributesFilter
7
+ # Hard cap on the number of extra_attributes values accepted from
8
+ # a single request. Defense-in-depth against a crafted query string
9
+ # that would otherwise allocate an arbitrarily large Set. Rack caps
10
+ # total param keys per request upstream; this caps how many of those
11
+ # Trane itself will materialise into a Set for one filter.
12
+ #
13
+ # Sized well above any realistic legitimate API surface (single
14
+ # endpoints typically declare < 20 extra fields).
15
+ MAX_VALUES = 100
16
+
17
+ # Frozen, shared sentinel returned for every input that parses to no
18
+ # extras (nil, non-iterable, or empty Array). Safe to share — callers
19
+ # only read via `include?` (audited: serializer.rb:48 is the sole
20
+ # consumer; zero mutations across trane/lib/).
21
+ EMPTY = Set.new.freeze
22
+
23
+ # Parse extra_attributes from request params into a Set of dot-notation paths.
24
+ #
25
+ # @param params [Hash, ActionController::Parameters] request params
26
+ # @return [Set<String>]
27
+ def self.parse(params)
28
+ raw = params[:extra_attributes]
29
+ return EMPTY if raw.nil?
30
+
31
+ values = case raw
32
+ when Array then raw
33
+ when String then [ raw ]
34
+ else []
35
+ end
36
+
37
+ return EMPTY if values.empty?
38
+ Set.new(values.first(MAX_VALUES).map(&:to_s))
39
+ end
40
+ end
41
+ end