rails-http-lab 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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +15 -0
  3. data/README.md +60 -0
  4. data/app/assets/javascripts/rails_http_lab/application.js +1318 -0
  5. data/app/assets/stylesheets/rails_http_lab/application.css +336 -0
  6. data/app/controllers/rails_http_lab/application_controller.rb +47 -0
  7. data/app/controllers/rails_http_lab/collections_controller.rb +20 -0
  8. data/app/controllers/rails_http_lab/environments_controller.rb +33 -0
  9. data/app/controllers/rails_http_lab/folders_controller.rb +47 -0
  10. data/app/controllers/rails_http_lab/requests_controller.rb +99 -0
  11. data/app/controllers/rails_http_lab/runs_controller.rb +34 -0
  12. data/app/controllers/rails_http_lab/ui_controller.rb +7 -0
  13. data/app/views/layouts/rails_http_lab.html.erb +14 -0
  14. data/app/views/rails_http_lab/ui/index.html.erb +103 -0
  15. data/config/routes.rb +24 -0
  16. data/lib/generators/rails_http_lab/install/install_generator.rb +41 -0
  17. data/lib/generators/rails_http_lab/install/templates/initializer.rb.tt +20 -0
  18. data/lib/rails-http-lab.rb +1 -0
  19. data/lib/rails_http_lab/bruno/block.rb +44 -0
  20. data/lib/rails_http_lab/bruno/document.rb +36 -0
  21. data/lib/rails_http_lab/bruno/parser.rb +207 -0
  22. data/lib/rails_http_lab/bruno/serializer.rb +68 -0
  23. data/lib/rails_http_lab/bruno.rb +12 -0
  24. data/lib/rails_http_lab/configuration.rb +51 -0
  25. data/lib/rails_http_lab/engine.rb +25 -0
  26. data/lib/rails_http_lab/execution/response.rb +20 -0
  27. data/lib/rails_http_lab/execution/runner.rb +187 -0
  28. data/lib/rails_http_lab/execution/variable_resolver.rb +30 -0
  29. data/lib/rails_http_lab/execution.rb +3 -0
  30. data/lib/rails_http_lab/storage/filesystem.rb +123 -0
  31. data/lib/rails_http_lab/storage/tree.rb +120 -0
  32. data/lib/rails_http_lab/storage.rb +2 -0
  33. data/lib/rails_http_lab/version.rb +3 -0
  34. data/lib/rails_http_lab.rb +14 -0
  35. metadata +92 -0
@@ -0,0 +1,336 @@
1
+ :root {
2
+ --rhl-red: #CC0000;
3
+ --rhl-red-dark: #A30000;
4
+ --rhl-red-soft: #F4D6D6;
5
+ --rhl-cream: #FAF7F0;
6
+ --rhl-ink: #1A1A1A;
7
+ --rhl-muted: #6B6B6B;
8
+ --rhl-border: #E5E1D8;
9
+ --rhl-bg-alt: #F1ECE0;
10
+ --rhl-success: #2E7D32;
11
+ --rhl-warn: #E89B00;
12
+ --rhl-verb-get: #2E7D32;
13
+ --rhl-verb-post: #6A1B9A;
14
+ --rhl-verb-put: #1565C0;
15
+ --rhl-verb-patch: #00796B;
16
+ --rhl-verb-del: #C62828;
17
+ }
18
+
19
+ * { box-sizing: border-box; }
20
+ [hidden] { display: none !important; }
21
+
22
+ html, body.rhl-body {
23
+ margin: 0; padding: 0; height: 100%;
24
+ background: var(--rhl-cream);
25
+ color: var(--rhl-ink);
26
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
27
+ font-size: 14px;
28
+ }
29
+
30
+ .rhl-app { display: flex; flex-direction: column; height: 100vh; }
31
+
32
+ .rhl-topbar {
33
+ display: flex; align-items: center; justify-content: space-between;
34
+ padding: 10px 16px;
35
+ background: var(--rhl-red);
36
+ color: white;
37
+ border-bottom: 2px solid var(--rhl-red-dark);
38
+ }
39
+ .rhl-topbar__brand { font-weight: 700; letter-spacing: 0.3px; }
40
+ .rhl-topbar__env { display: flex; align-items: center; gap: 8px; }
41
+ .rhl-topbar__env label { font-size: 12px; opacity: 0.9; }
42
+ .rhl-topbar__env select {
43
+ background: white; color: var(--rhl-ink);
44
+ border: none; padding: 4px 8px; border-radius: 4px;
45
+ }
46
+ .rhl-topbar__env .rhl-btn--ghost {
47
+ background: rgba(255,255,255,0.15); color: white; border-color: transparent;
48
+ }
49
+ .rhl-topbar__env .rhl-btn--ghost:hover { background: rgba(255,255,255,0.3); }
50
+
51
+ .rhl-main { display: flex; flex: 1; min-height: 0; }
52
+
53
+ .rhl-sidebar {
54
+ width: 280px; border-right: 1px solid var(--rhl-border);
55
+ background: var(--rhl-bg-alt); display: flex; flex-direction: column;
56
+ }
57
+ .rhl-sidebar__header {
58
+ padding: 12px 14px; font-weight: 600; border-bottom: 1px solid var(--rhl-border);
59
+ display: flex; align-items: center; justify-content: space-between;
60
+ }
61
+ .rhl-sidebar__tree { padding: 8px 0; overflow-y: auto; flex: 1; font-size: 13px; }
62
+ .rhl-tree-empty { padding: 12px 14px; color: var(--rhl-muted); font-size: 12px; }
63
+ .rhl-tree-node { padding: 4px 14px; cursor: pointer; user-select: none; display: flex; gap: 8px; align-items: center; }
64
+ .rhl-tree-node:hover { background: var(--rhl-red-soft); }
65
+ .rhl-tree-node.is-active { background: var(--rhl-red-soft); }
66
+ .rhl-tree-node.is-folder { font-weight: 600; }
67
+ .rhl-tree-caret {
68
+ display: inline-flex; align-items: center; justify-content: center;
69
+ width: 16px; flex: 0 0 16px; font-size: 18px; line-height: 1;
70
+ color: var(--rhl-muted);
71
+ }
72
+ .rhl-tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
73
+ .rhl-dirty-dot { color: var(--rhl-warn); margin-left: 6px; font-size: 9px; vertical-align: middle; }
74
+ .rhl-tree-action {
75
+ background: transparent; border: none; cursor: pointer; color: var(--rhl-muted);
76
+ padding: 0 6px; font-size: 16px; line-height: 1; border-radius: 4px;
77
+ opacity: 0; flex: 0 0 auto;
78
+ }
79
+ .rhl-tree-node:hover .rhl-tree-action { opacity: 1; }
80
+ .rhl-tree-action:hover { background: white; color: var(--rhl-red); }
81
+
82
+ .rhl-menu {
83
+ position: fixed; z-index: 1100; background: white;
84
+ border: 1px solid var(--rhl-border); border-radius: 6px;
85
+ box-shadow: 0 6px 18px rgba(0,0,0,0.14); padding: 4px; min-width: 160px;
86
+ }
87
+ .rhl-menu__item {
88
+ display: block; width: 100%; text-align: left; background: transparent; border: none;
89
+ padding: 6px 10px; cursor: pointer; border-radius: 4px; color: var(--rhl-ink);
90
+ font: inherit;
91
+ }
92
+ .rhl-menu__item:hover { background: var(--rhl-red-soft); color: var(--rhl-red); }
93
+ .rhl-menu__item--danger { color: var(--rhl-red); }
94
+ .rhl-menu__item--danger:hover { background: var(--rhl-red); color: white; }
95
+ .rhl-verb-badge {
96
+ font-size: 10px; font-weight: 700; padding: 1px 4px; border-radius: 3px;
97
+ color: white; min-width: 40px; text-align: center;
98
+ }
99
+ .rhl-verb-badge.GET { background: var(--rhl-verb-get); }
100
+ .rhl-verb-badge.POST { background: var(--rhl-verb-post); }
101
+ .rhl-verb-badge.PUT { background: var(--rhl-verb-put); }
102
+ .rhl-verb-badge.PATCH { background: var(--rhl-verb-patch); }
103
+ .rhl-verb-badge.DELETE { background: var(--rhl-verb-del); }
104
+ .rhl-verb-badge.HEAD,
105
+ .rhl-verb-badge.OPTIONS { background: var(--rhl-muted); }
106
+
107
+ .rhl-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
108
+ .rhl-pane__empty {
109
+ margin: auto; color: var(--rhl-muted); font-size: 15px;
110
+ max-width: 560px; padding: 24px; text-align: center; line-height: 1.5;
111
+ }
112
+ .rhl-pane__empty p { margin: 0 0 14px; }
113
+ .rhl-pane__empty p:last-child { margin-bottom: 0; }
114
+ .rhl-tip {
115
+ background: var(--rhl-bg-alt); border: 1px solid var(--rhl-border);
116
+ border-left: 3px solid var(--rhl-red); border-radius: 4px;
117
+ padding: 12px 14px; text-align: left; font-size: 13px; color: var(--rhl-ink);
118
+ }
119
+ .rhl-tip code {
120
+ background: white; padding: 1px 6px; border-radius: 3px;
121
+ border: 1px solid var(--rhl-border); font-size: 12px;
122
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
123
+ }
124
+ .rhl-pane__editor { display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 16px; gap: 12px; }
125
+
126
+ .rhl-request-bar { display: flex; gap: 8px; align-items: stretch; }
127
+ .rhl-verb {
128
+ font-weight: 700; padding: 0 10px; border: 1px solid var(--rhl-border);
129
+ background: white; border-radius: 4px;
130
+ }
131
+ .rhl-url {
132
+ flex: 1; padding: 8px 10px; border: 1px solid var(--rhl-border);
133
+ border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
134
+ background: white;
135
+ }
136
+ .rhl-btn {
137
+ padding: 8px 14px; border: 1px solid var(--rhl-border);
138
+ background: white; cursor: pointer; border-radius: 4px; font-weight: 600;
139
+ }
140
+ .rhl-btn:hover { background: var(--rhl-bg-alt); }
141
+ .rhl-btn--send {
142
+ background: var(--rhl-red); color: white; border-color: var(--rhl-red-dark);
143
+ }
144
+ .rhl-btn--send:hover { background: var(--rhl-red-dark); }
145
+ .rhl-btn--ghost { background: transparent; border-color: transparent; color: var(--rhl-red); }
146
+ .rhl-btn--ghost:hover { background: var(--rhl-red-soft); }
147
+ #rhl-save.is-dirty::after { content: "●"; color: var(--rhl-warn); margin-left: 6px; font-size: 9px; vertical-align: middle; }
148
+
149
+ .rhl-iconbtn {
150
+ background: transparent; border: none; cursor: pointer; font-size: 16px;
151
+ color: var(--rhl-ink); padding: 2px 8px; border-radius: 4px;
152
+ }
153
+ .rhl-iconbtn:hover { background: var(--rhl-red-soft); }
154
+
155
+ .rhl-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--rhl-border); flex-shrink: 0; }
156
+ .rhl-tab {
157
+ background: transparent; border: none; padding: 10px 14px; cursor: pointer;
158
+ color: var(--rhl-muted); font-weight: 600; border-bottom: 2px solid transparent;
159
+ }
160
+ .rhl-tab.is-active { color: var(--rhl-red); border-bottom-color: var(--rhl-red); }
161
+
162
+ /* The split between request panel (top) and response (bottom), with a splitter. */
163
+ .rhl-split {
164
+ display: flex; flex-direction: column; flex: 1; min-height: 0;
165
+ }
166
+
167
+ .rhl-tab-panel {
168
+ flex: 1 1 0; min-height: 100px; padding: 12px 0;
169
+ overflow: auto;
170
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
171
+ font-size: 13px;
172
+ }
173
+
174
+ .rhl-tab-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px dashed var(--rhl-border); }
175
+ .rhl-tab-head__label { font-size: 12px; color: var(--rhl-muted); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
176
+ .rhl-select { padding: 4px 8px; border: 1px solid var(--rhl-border); border-radius: 4px; background: white; }
177
+
178
+ .rhl-hint { padding: 10px 12px; color: var(--rhl-muted); background: var(--rhl-bg-alt); border-radius: 4px; }
179
+ .rhl-error { padding: 10px 12px; color: var(--rhl-red); background: var(--rhl-red-soft); border-radius: 4px; }
180
+
181
+ .rhl-kv-table { width: 100%; border-collapse: collapse; margin-bottom: 8px; }
182
+ .rhl-kv-table th, .rhl-kv-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--rhl-border); }
183
+ .rhl-kv-table th { font-size: 11px; color: var(--rhl-muted); text-transform: uppercase; letter-spacing: 0.4px; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
184
+ .rhl-kv-table input { width: 100%; border: none; background: transparent; padding: 4px; font: inherit; }
185
+ .rhl-kv-table input:focus { outline: 1px solid var(--rhl-red); background: white; }
186
+ .rhl-kv-table__del { width: 36px; text-align: right; }
187
+
188
+ .rhl-textarea {
189
+ width: 100%; min-height: 220px; padding: 10px; border: 1px solid var(--rhl-border);
190
+ border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
191
+ font-size: 13px; background: white; color: var(--rhl-ink); display: block; resize: vertical;
192
+ }
193
+ .rhl-textarea--body { min-height: 280px; }
194
+ .rhl-textarea--readonly {
195
+ color: var(--rhl-muted);
196
+ background: var(--rhl-bg-alt);
197
+ cursor: not-allowed;
198
+ opacity: 0.75;
199
+ }
200
+ .rhl-textarea--readonly:focus { outline: 1px dashed var(--rhl-muted); }
201
+ .rhl-readonly-banner {
202
+ background: var(--rhl-bg-alt); border-left: 3px solid var(--rhl-muted);
203
+ color: var(--rhl-muted); padding: 8px 12px; border-radius: 4px;
204
+ margin-bottom: 10px; font-size: 12px; line-height: 1.4;
205
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
206
+ }
207
+
208
+ .rhl-form-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
209
+ .rhl-form-row label { width: 200px; color: var(--rhl-muted); font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
210
+ .rhl-form-row input {
211
+ flex: 1; padding: 6px 8px; border: 1px solid var(--rhl-border); border-radius: 4px;
212
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; background: white;
213
+ }
214
+ .rhl-input-wide {
215
+ flex: 1; padding: 6px 8px; border: 1px solid var(--rhl-border); border-radius: 4px; background: white;
216
+ }
217
+
218
+ /* Splitter handle */
219
+ .rhl-splitter {
220
+ height: 6px; cursor: row-resize; background: transparent; flex: 0 0 6px;
221
+ border-top: 1px solid var(--rhl-border); border-bottom: 1px solid var(--rhl-border);
222
+ position: relative;
223
+ }
224
+ .rhl-splitter:hover { background: var(--rhl-red-soft); }
225
+ .rhl-splitter::after {
226
+ content: ""; position: absolute; left: 50%; top: 2px; transform: translateX(-50%);
227
+ width: 40px; height: 0; border-top: 2px dotted var(--rhl-muted);
228
+ }
229
+
230
+ .rhl-response {
231
+ flex: 1 1 0;
232
+ display: flex; flex-direction: column; gap: 8px; min-height: 120px; padding-top: 8px;
233
+ }
234
+ .rhl-response__header {
235
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
236
+ border-bottom: 1px solid var(--rhl-border);
237
+ }
238
+ .rhl-response__tabs { display: flex; gap: 0; }
239
+ .rhl-response-tab {
240
+ background: transparent; border: none; padding: 6px 12px; cursor: pointer;
241
+ color: var(--rhl-muted); font-weight: 600; border-bottom: 2px solid transparent;
242
+ font-size: 13px; display: inline-flex; align-items: center; gap: 6px;
243
+ }
244
+ .rhl-response-tab.is-active { color: var(--rhl-red); border-bottom-color: var(--rhl-red); }
245
+ .rhl-response-tab__count {
246
+ background: var(--rhl-bg-alt); color: var(--rhl-muted);
247
+ font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px;
248
+ min-width: 18px; text-align: center;
249
+ }
250
+ .rhl-response-tab.is-active .rhl-response-tab__count {
251
+ background: var(--rhl-red-soft); color: var(--rhl-red);
252
+ }
253
+
254
+ .rhl-response__meta { display: flex; gap: 8px; font-size: 12px; align-items: center; }
255
+ .rhl-response__meta span { padding: 2px 8px; background: var(--rhl-bg-alt); border-radius: 3px; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
256
+ .rhl-pill { font-weight: 700; }
257
+ .rhl-pill--ok { background: #DCEEDC !important; color: var(--rhl-success); }
258
+ .rhl-pill--warn { background: #FFE9C7 !important; color: var(--rhl-warn); }
259
+ .rhl-pill--err { background: #F4D6D6 !important; color: var(--rhl-red); }
260
+ .rhl-pill--info { background: var(--rhl-bg-alt) !important; }
261
+
262
+ .rhl-response__body {
263
+ margin: 0; padding: 10px; background: #1A1A1A; color: #F1ECE0;
264
+ border-radius: 4px; overflow: auto; flex: 1;
265
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
266
+ white-space: pre-wrap; word-break: break-word;
267
+ }
268
+ .rhl-response__body--err { color: #FFB4B4; }
269
+
270
+ /* JSON syntax highlighting tokens (used inside .rhl-response__body) */
271
+ .rhl-tok--key { color: #82AAFF; }
272
+ .rhl-tok--str { color: #C3E88D; }
273
+ .rhl-tok--num { color: #F78C6C; }
274
+ .rhl-tok--bool { color: #C792EA; }
275
+ .rhl-tok--null { color: #FF6B6B; font-style: italic; }
276
+
277
+ .rhl-response__headers {
278
+ flex: 1; overflow: auto; background: white;
279
+ border: 1px solid var(--rhl-border); border-radius: 4px;
280
+ }
281
+ .rhl-headers-table { width: 100%; border-collapse: collapse; font-size: 12px; }
282
+ .rhl-headers-table th, .rhl-headers-table td {
283
+ text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--rhl-border);
284
+ vertical-align: top;
285
+ }
286
+ .rhl-headers-table th {
287
+ font-size: 11px; color: var(--rhl-muted); text-transform: uppercase; letter-spacing: 0.4px;
288
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
289
+ background: var(--rhl-bg-alt); position: sticky; top: 0;
290
+ }
291
+ .rhl-headers-table__name {
292
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
293
+ color: var(--rhl-ink); white-space: nowrap; width: 1%;
294
+ }
295
+ .rhl-headers-table__value {
296
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
297
+ color: var(--rhl-ink); word-break: break-all;
298
+ }
299
+
300
+ /* Modal */
301
+ .rhl-modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; }
302
+ .rhl-modal__backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.4); }
303
+ .rhl-modal__dialog {
304
+ position: relative; background: var(--rhl-cream); border: 1px solid var(--rhl-border);
305
+ border-radius: 8px; width: min(900px, 92vw); height: min(600px, 80vh); display: flex; flex-direction: column;
306
+ box-shadow: 0 8px 32px rgba(0,0,0,0.15);
307
+ }
308
+ .rhl-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--rhl-border); }
309
+ .rhl-modal__header h2 { margin: 0; font-size: 16px; }
310
+ .rhl-modal__body { display: flex; flex: 1; min-height: 0; }
311
+ .rhl-envs-list { width: 220px; border-right: 1px solid var(--rhl-border); padding: 8px 0; overflow-y: auto; }
312
+ .rhl-envs-list__item { padding: 8px 14px; cursor: pointer; }
313
+ .rhl-envs-list__item:hover { background: var(--rhl-red-soft); }
314
+ .rhl-envs-editor { flex: 1; padding: 14px 18px; overflow-y: auto; }
315
+ .rhl-envs-editor h3 { margin: 0 0 12px; }
316
+ .rhl-envs-editor__footer { margin-top: 12px; display: flex; gap: 8px; }
317
+
318
+ .rhl-modal__footer {
319
+ padding: 10px 16px;
320
+ border-top: 1px solid var(--rhl-border);
321
+ background: var(--rhl-bg-alt);
322
+ color: var(--rhl-muted);
323
+ font-size: 12px;
324
+ line-height: 1.5;
325
+ border-bottom-left-radius: 8px;
326
+ border-bottom-right-radius: 8px;
327
+ }
328
+ .rhl-modal__footer code {
329
+ background: white;
330
+ border: 1px solid var(--rhl-border);
331
+ border-radius: 3px;
332
+ padding: 1px 5px;
333
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
334
+ font-size: 11px;
335
+ color: var(--rhl-ink);
336
+ }
@@ -0,0 +1,47 @@
1
+ module RailsHttpLab
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception, prepend: true
4
+ layout "rails_http_lab"
5
+
6
+ before_action :guard_environment!
7
+
8
+ rescue_from RailsHttpLab::NotFoundError, with: :render_not_found
9
+ rescue_from RailsHttpLab::OutsideStorageError, with: :render_forbidden
10
+ rescue_from RailsHttpLab::ParseError, with: :render_unprocessable
11
+ rescue_from RailsHttpLab::Error, with: :render_unprocessable
12
+
13
+ private
14
+
15
+ def guard_environment!
16
+ cfg = RailsHttpLab.config
17
+ env_sym = Rails.env.to_sym
18
+ unless cfg.enabled_envs.include?(env_sym)
19
+ head :not_found and return
20
+ end
21
+ if cfg.authenticator && !cfg.authenticator.call(request)
22
+ head :forbidden and return
23
+ end
24
+ end
25
+
26
+ def render_not_found(error)
27
+ respond_to do |format|
28
+ format.json { render json: { error: error.message }, status: :not_found }
29
+ format.any { head :not_found }
30
+ end
31
+ end
32
+
33
+ def render_forbidden(error)
34
+ respond_to do |format|
35
+ format.json { render json: { error: error.message }, status: :forbidden }
36
+ format.any { head :forbidden }
37
+ end
38
+ end
39
+
40
+ def render_unprocessable(error)
41
+ respond_to do |format|
42
+ format.json { render json: { error: error.message }, status: :unprocessable_entity }
43
+ format.any { head :unprocessable_entity }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,20 @@
1
+ module RailsHttpLab
2
+ class CollectionsController < ApplicationController
3
+ skip_forgery_protection only: [:create]
4
+
5
+ def tree
6
+ RailsHttpLab::Storage::Filesystem.new.ensure_root!
7
+ render json: RailsHttpLab::Storage::Tree.new.build
8
+ end
9
+
10
+ def create
11
+ name = params.require(:name).to_s
12
+ fs = RailsHttpLab::Storage::Filesystem.new
13
+ fs.ensure_root!
14
+ manifest = fs.read_bruno_json || {}
15
+ manifest["name"] = name if manifest["name"].to_s.empty?
16
+ File.write(File.join(fs.root.to_s, "bruno.json"), JSON.pretty_generate(manifest) + "\n")
17
+ render json: { ok: true, name: manifest["name"] }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ module RailsHttpLab
2
+ class EnvironmentsController < ApplicationController
3
+ skip_forgery_protection only: [:update]
4
+
5
+ def index
6
+ render json: { environments: RailsHttpLab::Storage::Tree.new.build[:environments] }
7
+ end
8
+
9
+ def show
10
+ doc = storage.read_bru("environments/#{params[:name]}.bru")
11
+ vars = doc.block("vars")&.pairs || []
12
+ render json: { name: params[:name], vars: vars }
13
+ end
14
+
15
+ def update
16
+ pairs = Array(params[:vars]).map { |v|
17
+ v = v.to_unsafe_h if v.respond_to?(:to_unsafe_h)
18
+ [v["key"].to_s, v["value"].to_s]
19
+ }
20
+ doc = RailsHttpLab::Bruno::Document.new(blocks: [
21
+ RailsHttpLab::Bruno::Block.new(name: "vars", mode: :kv, pairs: pairs)
22
+ ])
23
+ storage.write_bru("environments/#{params[:name]}.bru", doc)
24
+ render json: { ok: true }
25
+ end
26
+
27
+ private
28
+
29
+ def storage
30
+ @storage ||= RailsHttpLab::Storage::Filesystem.new
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ module RailsHttpLab
2
+ class FoldersController < ApplicationController
3
+ skip_forgery_protection only: [:create, :rename, :destroy]
4
+
5
+ def create
6
+ path = params.require(:path).to_s
7
+ RailsHttpLab::Storage::Filesystem.new.create_folder(path, display_name: params[:name])
8
+ render json: { ok: true, path: path }
9
+ end
10
+
11
+ def rename
12
+ from = params.require(:path).to_s
13
+ new_name = params.require(:name).to_s.strip
14
+ raise RailsHttpLab::Error, "name cannot be empty" if new_name.empty?
15
+ raise RailsHttpLab::Error, "name cannot contain '/' or '\\'" if new_name.match?(%r{[/\\]})
16
+
17
+ parent = File.dirname(from)
18
+ parent = "" if parent == "."
19
+ to = parent.empty? ? new_name : "#{parent}/#{new_name}"
20
+
21
+ fs = RailsHttpLab::Storage::Filesystem.new
22
+ fs.rename(from, to)
23
+ update_folder_meta_name(fs, to, new_name)
24
+
25
+ render json: { ok: true, path: to }
26
+ end
27
+
28
+ def destroy
29
+ path = params.require(:path).to_s
30
+ RailsHttpLab::Storage::Filesystem.new.delete(path)
31
+ head :no_content
32
+ end
33
+
34
+ private
35
+
36
+ def update_folder_meta_name(fs, folder_path, new_name)
37
+ meta_path = "#{folder_path}/folder.bru"
38
+ doc = fs.read_bru(meta_path)
39
+ meta = doc.block("meta")
40
+ return unless meta&.kv?
41
+ meta["name"] = new_name
42
+ fs.write_bru(meta_path, doc)
43
+ rescue RailsHttpLab::NotFoundError
44
+ # folder had no folder.bru — nothing to update
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,99 @@
1
+ module RailsHttpLab
2
+ class RequestsController < ApplicationController
3
+ skip_forgery_protection only: [:create, :update, :destroy, :rename]
4
+
5
+ def show
6
+ doc = storage.read_bru(path_param)
7
+ render json: serialize(doc, path_param)
8
+ end
9
+
10
+ def update
11
+ doc = doc_from_params
12
+ storage.write_bru(path_param, doc)
13
+ render json: serialize(doc, path_param)
14
+ end
15
+
16
+ def create
17
+ rel = params.require(:path).to_s
18
+ doc = doc_from_params
19
+ storage.write_bru(rel, doc)
20
+ render json: serialize(doc, rel)
21
+ end
22
+
23
+ def destroy
24
+ storage.delete(path_param)
25
+ head :no_content
26
+ end
27
+
28
+ def rename
29
+ from = params.require(:path).to_s
30
+ new_name = params.require(:name).to_s.strip
31
+ raise RailsHttpLab::Error, "name cannot be empty" if new_name.empty?
32
+ raise RailsHttpLab::Error, "name cannot contain '/' or '\\'" if new_name.match?(%r{[/\\]})
33
+
34
+ base = new_name.sub(/\.bru\z/i, "")
35
+ parent = File.dirname(from)
36
+ parent = "" if parent == "."
37
+ to = parent.empty? ? "#{base}.bru" : "#{parent}/#{base}.bru"
38
+
39
+ storage.rename(from, to)
40
+ doc = storage.read_bru(to)
41
+ meta = doc.block("meta")
42
+ if meta&.kv?
43
+ meta["name"] = base
44
+ storage.write_bru(to, doc)
45
+ end
46
+
47
+ render json: serialize(doc, to)
48
+ end
49
+
50
+ private
51
+
52
+ def storage
53
+ @storage ||= RailsHttpLab::Storage::Filesystem.new
54
+ end
55
+
56
+ def path_param
57
+ params[:path].to_s
58
+ end
59
+
60
+ def doc_from_params
61
+ raw = params[:source].to_s
62
+ return RailsHttpLab::Bruno.parse(raw) unless raw.empty?
63
+
64
+ RailsHttpLab::Bruno.parse(blocks_to_source(params[:blocks]))
65
+ end
66
+
67
+ # Accepts an array of { name, mode, pairs|raw } and rebuilds a Document.
68
+ def blocks_to_source(blocks)
69
+ blocks = blocks.respond_to?(:to_unsafe_h) ? blocks.to_unsafe_h.values : blocks
70
+ docs = []
71
+ Array(blocks).each do |b|
72
+ b = b.to_unsafe_h if b.respond_to?(:to_unsafe_h)
73
+ mode = b["mode"]&.to_sym || :kv
74
+ if mode == :raw
75
+ docs << "#{b['name']} {\n#{b['raw']}\n}\n"
76
+ else
77
+ lines = Array(b["pairs"]).map { |k, v| " #{k}: #{v}" }.join("\n")
78
+ docs << "#{b['name']} {\n#{lines}#{lines.empty? ? '' : "\n"}}\n"
79
+ end
80
+ end
81
+ docs.join("\n")
82
+ end
83
+
84
+ def serialize(doc, rel)
85
+ {
86
+ path: rel,
87
+ method: doc.http_method,
88
+ url: doc.url,
89
+ blocks: doc.blocks.map { |b|
90
+ if b.kv?
91
+ { name: b.name, mode: "kv", pairs: b.pairs }
92
+ else
93
+ { name: b.name, mode: "raw", raw: b.raw }
94
+ end
95
+ }
96
+ }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,34 @@
1
+ module RailsHttpLab
2
+ class RunsController < ApplicationController
3
+ skip_forgery_protection only: [:create]
4
+
5
+ def create
6
+ doc = build_document
7
+ resolver = build_resolver
8
+ response = RailsHttpLab::Execution::Runner.new(doc, resolver: resolver).run
9
+ render json: response.to_h
10
+ end
11
+
12
+ private
13
+
14
+ def build_document
15
+ if params[:path].present?
16
+ RailsHttpLab::Storage::Filesystem.new.read_bru(params[:path])
17
+ elsif params[:source].present?
18
+ RailsHttpLab::Bruno.parse(params[:source].to_s)
19
+ else
20
+ raise RailsHttpLab::Error, "missing :path or :source"
21
+ end
22
+ end
23
+
24
+ def build_resolver
25
+ env_name = params[:environment].to_s
26
+ return RailsHttpLab::Execution::VariableResolver.new if env_name.empty?
27
+
28
+ env_doc = RailsHttpLab::Storage::Filesystem.new.read_bru("environments/#{env_name}.bru")
29
+ RailsHttpLab::Execution::VariableResolver.from_environment_document(env_doc)
30
+ rescue RailsHttpLab::NotFoundError
31
+ RailsHttpLab::Execution::VariableResolver.new
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,7 @@
1
+ module RailsHttpLab
2
+ class UiController < ApplicationController
3
+ skip_forgery_protection only: [] # placeholder
4
+ def index
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Rails HTTP Lab</title>
5
+ <meta name="rhl-base" content="<%= request.script_name %>">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag if respond_to?(:csp_meta_tag) %>
8
+ <%= stylesheet_link_tag "rails_http_lab/application", media: "all" %>
9
+ </head>
10
+ <body class="rhl-body">
11
+ <%= yield %>
12
+ <%= javascript_include_tag "rails_http_lab/application", defer: true %>
13
+ </body>
14
+ </html>