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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/lib/tasks/trane.rake +17 -0
- data/lib/trane/boot_validator.rb +64 -0
- data/lib/trane/configuration.rb +146 -0
- data/lib/trane/contract_loader.rb +29 -0
- data/lib/trane/contract_validator.rb +88 -0
- data/lib/trane/controller/error_handler.rb +169 -0
- data/lib/trane/controller/renderer.rb +104 -0
- data/lib/trane/controller.rb +26 -0
- data/lib/trane/docs/app.rb +36 -0
- data/lib/trane/docs/cache.rb +76 -0
- data/lib/trane/docs/html_renderer.rb +104 -0
- data/lib/trane/docs/service_definition.rb +173 -0
- data/lib/trane/docs/templates/index.html.erb +489 -0
- data/lib/trane/engine.rb +148 -0
- data/lib/trane/error_registry.rb +30 -0
- data/lib/trane/extra_attributes_filter.rb +41 -0
- data/lib/trane/field_builder.rb +81 -0
- data/lib/trane/field_node.rb +18 -0
- data/lib/trane/operation_definition.rb +146 -0
- data/lib/trane/param_definition.rb +25 -0
- data/lib/trane/registry.rb +324 -0
- data/lib/trane/representation_definition.rb +24 -0
- data/lib/trane/route_validator.rb +89 -0
- data/lib/trane/routing_extension.rb +85 -0
- data/lib/trane/serializer.rb +147 -0
- data/lib/trane/testing.rb +40 -0
- data/lib/trane/types.rb +67 -0
- data/lib/trane/version.rb +5 -0
- data/lib/trane.rb +111 -0
- metadata +167 -0
|
@@ -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>
|
data/lib/trane/engine.rb
ADDED
|
@@ -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
|