rails-http-lab 0.1.0 → 0.1.1
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 +4 -4
- data/README.md +30 -1
- data/app/assets/javascripts/rails_http_lab/application.js +63 -10
- data/app/assets/stylesheets/rails_http_lab/application.css +1 -0
- data/app/controllers/rails_http_lab/application_controller.rb +2 -2
- data/app/controllers/rails_http_lab/environments_controller.rb +18 -4
- data/app/controllers/rails_http_lab/requests_controller.rb +4 -0
- data/lib/rails_http_lab/bruno/block.rb +14 -1
- data/lib/rails_http_lab/bruno/parser.rb +23 -3
- data/lib/rails_http_lab/bruno/serializer.rb +5 -4
- data/lib/rails_http_lab/execution/runner.rb +43 -15
- data/lib/rails_http_lab/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09a056aea1f71d9c958b238c6eb6bda36ac6cb982315bc19b540652926c05726'
|
|
4
|
+
data.tar.gz: a469930e6fc214f3ca3310581ea20210fa5bba233d3aefba9c6095bbfa55b1e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1f002fe432bd2ba8ea8757b26c83d1614bd27f665155493596bbcfe90feeb28fb4eb6ef9ad454d71480df37daab8ea50a3aff4308473da3cd730803af6d8aab
|
|
7
|
+
data.tar.gz: 1771183d8b275004bb49e393d1d9b631df83e76ed6f5962d7ca51071d20f311acfd1910c9963013b8c5a8919587f5b57d6b91d202d4e15c11cd857e70cb03956
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ In-app HTTP request lab for Rails. Mounts a UI at `/rails/http-lab` and persists
|
|
|
4
4
|
|
|
5
5
|
No external app, no separate workspace — your requests live next to the code that serves them, version-controlled in `docs/http-lab/`.
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
@@ -30,6 +30,21 @@ bin/rails g rails_http_lab:install
|
|
|
30
30
|
|
|
31
31
|
Visit `http://localhost:3000/rails/http-lab`.
|
|
32
32
|
|
|
33
|
+
## Compatibility
|
|
34
|
+
|
|
35
|
+
Requires Ruby `>= 3.1` and Rails `>= 7.0`. Every combination below runs in
|
|
36
|
+
[CI](.github/workflows/ci.yml):
|
|
37
|
+
|
|
38
|
+
| Ruby \ Rails | 7.0 | 7.1 | 7.2 | 8.0 | 8.1 |
|
|
39
|
+
|--------------|:---:|:---:|:---:|:---:|:---:|
|
|
40
|
+
| **4.0** | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
41
|
+
| **3.4** | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
42
|
+
| **3.3** | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
43
|
+
| **3.2** | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
44
|
+
| **3.1** | ✅ | ✅ | ✅ | — | — |
|
|
45
|
+
|
|
46
|
+
— Rails 8.x itself requires Ruby `>= 3.2`.
|
|
47
|
+
|
|
33
48
|
## Configuration
|
|
34
49
|
|
|
35
50
|
`config/initializers/rails_http_lab.rb`:
|
|
@@ -55,6 +70,20 @@ risk if exposed to untrusted users. Defaults:
|
|
|
55
70
|
- Bruno `script` and `tests` blocks are **persisted but not executed** to avoid
|
|
56
71
|
arbitrary code execution. Run them in Bruno itself.
|
|
57
72
|
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bundle install
|
|
77
|
+
bundle exec rspec
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To run the suite against a specific Rails series (as CI does), set
|
|
81
|
+
`RAILS_VERSION` before installing:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
RAILS_VERSION=7.2 bundle update rails && RAILS_VERSION=7.2 bundle exec rspec
|
|
85
|
+
```
|
|
86
|
+
|
|
58
87
|
## License
|
|
59
88
|
|
|
60
89
|
MIT
|
|
@@ -86,13 +86,25 @@
|
|
|
86
86
|
function normalizedSource(doc) {
|
|
87
87
|
if (!doc) return "";
|
|
88
88
|
const blocks = (doc.blocks || []).filter((b) => {
|
|
89
|
-
if (b.mode === "raw") return (b.raw || "").trim() !== "";
|
|
89
|
+
if (b.mode === "raw" || b.mode === "list") return (b.raw || "").trim() !== "";
|
|
90
90
|
const pairs = (b.pairs || []).filter(([k, v]) =>
|
|
91
91
|
(k || "").trim() !== "" || (v || "").trim() !== "");
|
|
92
92
|
return pairs.length > 0;
|
|
93
93
|
});
|
|
94
94
|
return serializeDoc({ blocks });
|
|
95
95
|
}
|
|
96
|
+
// Viewing a tab lazily injects an empty block (e.g. `params:query {}`).
|
|
97
|
+
// Drop those before writing/sending so they don't pollute the .bru file —
|
|
98
|
+
// but keep empty blocks that were already on disk, for byte-stable round-trip.
|
|
99
|
+
function isEmptyBlock(b) {
|
|
100
|
+
if (b.mode === "raw" || b.mode === "list") return (b.raw || "").trim() === "";
|
|
101
|
+
return !(b.pairs || []).some(([k, v]) => (k || "").trim() !== "" || (v || "").trim() !== "");
|
|
102
|
+
}
|
|
103
|
+
function persistableDoc(session) {
|
|
104
|
+
const onDisk = new Set((session.savedDoc?.blocks || []).map((b) => b.name));
|
|
105
|
+
return { blocks: (session.doc.blocks || []).filter((b) => !isEmptyBlock(b) || onDisk.has(b.name)) };
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
function isDirty(session) {
|
|
97
109
|
if (!session) return false;
|
|
98
110
|
return normalizedSource(session.doc) !== normalizedSource(session.savedDoc);
|
|
@@ -201,10 +213,11 @@
|
|
|
201
213
|
// ---------- .bru source serializer (client-side) ----------
|
|
202
214
|
function serializeDoc(doc) {
|
|
203
215
|
return (doc.blocks || []).map((b) => {
|
|
204
|
-
if (b.mode === "raw") {
|
|
216
|
+
if (b.mode === "raw" || b.mode === "list") {
|
|
217
|
+
const [open, close] = b.mode === "list" ? ["[", "]"] : ["{", "}"];
|
|
205
218
|
const body = b.raw || "";
|
|
206
219
|
const tail = body.endsWith("\n") || body === "" ? "" : "\n";
|
|
207
|
-
return `${b.name} {\n${body}${tail}}\n`;
|
|
220
|
+
return `${b.name} ${open}\n${body}${tail}${close}\n`;
|
|
208
221
|
}
|
|
209
222
|
const lines = (b.pairs || []).map(([k, v]) => ` ${k}: ${v}`).join("\n");
|
|
210
223
|
return `${b.name} {\n${lines}${lines ? "\n" : ""}}\n`;
|
|
@@ -300,6 +313,7 @@
|
|
|
300
313
|
const s = state.sessions.get(hydratedKey);
|
|
301
314
|
if (!s) return;
|
|
302
315
|
state.currentKey = hydratedKey;
|
|
316
|
+
ensureExpanded(parentDir(hydratedKey)); // don't restore into a collapsed folder
|
|
303
317
|
showEditorForSession(s);
|
|
304
318
|
}
|
|
305
319
|
|
|
@@ -678,6 +692,7 @@
|
|
|
678
692
|
|
|
679
693
|
function renderKvTab(panel, blockName, placeholder) {
|
|
680
694
|
const block = ensureKvBlock(blockName);
|
|
695
|
+
const changed = blockName === "params:query" ? syncUrlFromParams : () => {};
|
|
681
696
|
const table = ce("table", { class: "rhl-kv-table" });
|
|
682
697
|
const thead = ce("thead", {}, ce("tr", {}, ce("th", {}, "Name"), ce("th", {}, "Value"), ce("th", {})));
|
|
683
698
|
const tbody = ce("tbody");
|
|
@@ -688,15 +703,15 @@
|
|
|
688
703
|
const tr = ce("tr");
|
|
689
704
|
tr.appendChild(ce("td", {}, ce("input", {
|
|
690
705
|
value: pair[0] || "", placeholder,
|
|
691
|
-
oninput: (e) => { block.pairs[i][0] = e.target.value; }
|
|
706
|
+
oninput: (e) => { block.pairs[i][0] = e.target.value; changed(); }
|
|
692
707
|
})));
|
|
693
708
|
tr.appendChild(ce("td", {}, ce("input", {
|
|
694
709
|
value: pair[1] || "", placeholder: "value",
|
|
695
|
-
oninput: (e) => { block.pairs[i][1] = e.target.value; }
|
|
710
|
+
oninput: (e) => { block.pairs[i][1] = e.target.value; changed(); }
|
|
696
711
|
})));
|
|
697
712
|
tr.appendChild(ce("td", { class: "rhl-kv-table__del" }, ce("button", {
|
|
698
713
|
class: "rhl-iconbtn",
|
|
699
|
-
onclick: () => { block.pairs.splice(i, 1); onDocEdited(); renderPanel(); }
|
|
714
|
+
onclick: () => { block.pairs.splice(i, 1); changed(); onDocEdited(); renderPanel(); }
|
|
700
715
|
}, "×")));
|
|
701
716
|
tbody.appendChild(tr);
|
|
702
717
|
});
|
|
@@ -826,6 +841,40 @@
|
|
|
826
841
|
}
|
|
827
842
|
}
|
|
828
843
|
|
|
844
|
+
// ---------- URL <-> params:query sync ----------
|
|
845
|
+
// Bruno keeps the URL's query string and the params:query block mirrored
|
|
846
|
+
// (and only sends the URL), so edits on either side must update the other.
|
|
847
|
+
// Disabled params (`~key`) live only in the block, never in the URL.
|
|
848
|
+
function splitUrl(url) {
|
|
849
|
+
const i = url.indexOf("?");
|
|
850
|
+
return i < 0 ? [url, ""] : [url.slice(0, i), url.slice(i + 1)];
|
|
851
|
+
}
|
|
852
|
+
function syncUrlFromParams() {
|
|
853
|
+
if (!currentDoc()) return;
|
|
854
|
+
const block = findBlock(currentDoc(), "params:query");
|
|
855
|
+
const enabled = (block?.pairs || []).filter(([k]) => k && !k.startsWith("~"));
|
|
856
|
+
const [base] = splitUrl($("#rhl-url").value);
|
|
857
|
+
const qs = enabled.map(([k, v]) => `${k}=${v || ""}`).join("&");
|
|
858
|
+
const url = qs ? `${base}?${qs}` : base;
|
|
859
|
+
$("#rhl-url").value = url;
|
|
860
|
+
setVerbPair("url", url);
|
|
861
|
+
}
|
|
862
|
+
function syncParamsFromUrl() {
|
|
863
|
+
const doc = currentDoc();
|
|
864
|
+
if (!doc) return;
|
|
865
|
+
const [, qs] = splitUrl($("#rhl-url").value);
|
|
866
|
+
const fromUrl = qs.split("&").filter(Boolean).map((part) => {
|
|
867
|
+
const i = part.indexOf("=");
|
|
868
|
+
return i < 0 ? [part, ""] : [part.slice(0, i), part.slice(i + 1)];
|
|
869
|
+
});
|
|
870
|
+
const existing = findBlock(doc, "params:query");
|
|
871
|
+
if (!existing && fromUrl.length === 0) return;
|
|
872
|
+
const block = existing || ensureKvBlock("params:query");
|
|
873
|
+
const disabled = block.pairs.filter(([k]) => (k || "").startsWith("~"));
|
|
874
|
+
block.pairs = [...fromUrl, ...disabled];
|
|
875
|
+
if (activeSession()?.activeTab === "params") renderPanel();
|
|
876
|
+
}
|
|
877
|
+
|
|
829
878
|
function formRow(label, block, field) {
|
|
830
879
|
const pair = block.pairs.find(([k]) => k === field);
|
|
831
880
|
const input = ce("input", {
|
|
@@ -885,7 +934,7 @@
|
|
|
885
934
|
state.envName = envName;
|
|
886
935
|
try {
|
|
887
936
|
const resp = await api("POST", "/run", {
|
|
888
|
-
source: serializeDoc(session
|
|
937
|
+
source: serializeDoc(persistableDoc(session)),
|
|
889
938
|
environment: envName
|
|
890
939
|
});
|
|
891
940
|
session.response = resp;
|
|
@@ -910,14 +959,14 @@
|
|
|
910
959
|
if (!path.endsWith(".bru")) path += ".bru";
|
|
911
960
|
}
|
|
912
961
|
try {
|
|
913
|
-
await api("PUT", `/requests/${encodePath(path)}`, { source: serializeDoc(session
|
|
962
|
+
await api("PUT", `/requests/${encodePath(path)}`, { source: serializeDoc(persistableDoc(session)) });
|
|
914
963
|
if (path !== session.path) {
|
|
915
964
|
state.sessions.delete(session.path);
|
|
916
965
|
session.path = path;
|
|
917
966
|
state.sessions.set(path, session);
|
|
918
967
|
state.currentKey = path;
|
|
919
968
|
}
|
|
920
|
-
session.savedDoc = cloneDoc(session
|
|
969
|
+
session.savedDoc = cloneDoc(persistableDoc(session)); // now clean (matches disk)
|
|
921
970
|
session._wasDirty = false;
|
|
922
971
|
ensureExpanded(parentDir(path));
|
|
923
972
|
await loadTree();
|
|
@@ -1300,7 +1349,11 @@
|
|
|
1300
1349
|
|
|
1301
1350
|
// Keep verb/url synced into the active doc as the user types.
|
|
1302
1351
|
$("#rhl-verb").addEventListener("change", () => { if (currentDoc()) setVerbName($("#rhl-verb").value.toLowerCase()); });
|
|
1303
|
-
$("#rhl-url").addEventListener("input", () => {
|
|
1352
|
+
$("#rhl-url").addEventListener("input", () => {
|
|
1353
|
+
if (!currentDoc()) return;
|
|
1354
|
+
setVerbPair("url", $("#rhl-url").value);
|
|
1355
|
+
syncParamsFromUrl();
|
|
1356
|
+
});
|
|
1304
1357
|
}
|
|
1305
1358
|
|
|
1306
1359
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
@@ -253,6 +253,7 @@ html, body.rhl-body {
|
|
|
253
253
|
|
|
254
254
|
.rhl-response__meta { display: flex; gap: 8px; font-size: 12px; align-items: center; }
|
|
255
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-response__meta span:empty { display: none; }
|
|
256
257
|
.rhl-pill { font-weight: 700; }
|
|
257
258
|
.rhl-pill--ok { background: #DCEEDC !important; color: var(--rhl-success); }
|
|
258
259
|
.rhl-pill--warn { background: #FFE9C7 !important; color: var(--rhl-warn); }
|
|
@@ -39,8 +39,8 @@ module RailsHttpLab
|
|
|
39
39
|
|
|
40
40
|
def render_unprocessable(error)
|
|
41
41
|
respond_to do |format|
|
|
42
|
-
format.json { render json: { error: error.message }, status:
|
|
43
|
-
format.any { head
|
|
42
|
+
format.json { render json: { error: error.message }, status: 422 }
|
|
43
|
+
format.any { head 422 }
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
end
|
|
@@ -17,15 +17,29 @@ module RailsHttpLab
|
|
|
17
17
|
v = v.to_unsafe_h if v.respond_to?(:to_unsafe_h)
|
|
18
18
|
[v["key"].to_s, v["value"].to_s]
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
rel = "environments/#{params[:name]}.bru"
|
|
21
|
+
doc = existing_document(rel) || RailsHttpLab::Bruno::Document.new
|
|
22
|
+
vars_block = doc.block("vars")
|
|
23
|
+
if vars_block
|
|
24
|
+
vars_block.pairs = pairs
|
|
25
|
+
else
|
|
26
|
+
# Bruno writes `vars` first, before e.g. `vars:secret [ ... ]`.
|
|
27
|
+
doc.blocks.unshift(RailsHttpLab::Bruno::Block.new(name: "vars", mode: :kv, pairs: pairs))
|
|
28
|
+
end
|
|
29
|
+
# Only `vars` is editable here; any other block (notably the
|
|
30
|
+
# `vars:secret` list Bruno desktop maintains) is written back untouched.
|
|
31
|
+
storage.write_bru(rel, doc)
|
|
24
32
|
render json: { ok: true }
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
private
|
|
28
36
|
|
|
37
|
+
def existing_document(rel)
|
|
38
|
+
storage.read_bru(rel)
|
|
39
|
+
rescue RailsHttpLab::NotFoundError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
def storage
|
|
30
44
|
@storage ||= RailsHttpLab::Storage::Filesystem.new
|
|
31
45
|
end
|
|
@@ -73,6 +73,8 @@ module RailsHttpLab
|
|
|
73
73
|
mode = b["mode"]&.to_sym || :kv
|
|
74
74
|
if mode == :raw
|
|
75
75
|
docs << "#{b['name']} {\n#{b['raw']}\n}\n"
|
|
76
|
+
elsif mode == :list
|
|
77
|
+
docs << "#{b['name']} [\n#{b['raw']}\n]\n"
|
|
76
78
|
else
|
|
77
79
|
lines = Array(b["pairs"]).map { |k, v| " #{k}: #{v}" }.join("\n")
|
|
78
80
|
docs << "#{b['name']} {\n#{lines}#{lines.empty? ? '' : "\n"}}\n"
|
|
@@ -89,6 +91,8 @@ module RailsHttpLab
|
|
|
89
91
|
blocks: doc.blocks.map { |b|
|
|
90
92
|
if b.kv?
|
|
91
93
|
{ name: b.name, mode: "kv", pairs: b.pairs }
|
|
94
|
+
elsif b.list?
|
|
95
|
+
{ name: b.name, mode: "list", raw: b.raw }
|
|
92
96
|
else
|
|
93
97
|
{ name: b.name, mode: "raw", raw: b.raw }
|
|
94
98
|
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
module RailsHttpLab
|
|
2
2
|
module Bruno
|
|
3
3
|
class Block
|
|
4
|
-
|
|
4
|
+
# :kv — `name {` + `key: value` lines
|
|
5
|
+
# :raw — `name {` + opaque body, verbatim
|
|
6
|
+
# :list — `name [` + one item per line (e.g. Bruno's `vars:secret [`),
|
|
7
|
+
# body kept verbatim in `raw` for byte-stable round-trip
|
|
8
|
+
MODES = %i[kv raw list].freeze
|
|
5
9
|
|
|
6
10
|
attr_accessor :name, :mode, :pairs, :raw
|
|
7
11
|
|
|
@@ -15,6 +19,13 @@ module RailsHttpLab
|
|
|
15
19
|
|
|
16
20
|
def kv?; mode == :kv; end
|
|
17
21
|
def raw?; mode == :raw; end
|
|
22
|
+
def list?; mode == :list; end
|
|
23
|
+
|
|
24
|
+
# Item names of a :list block. Bruno separates items with ",\n".
|
|
25
|
+
def items
|
|
26
|
+
return [] unless list?
|
|
27
|
+
raw.to_s.split("\n").map { |l| l.strip.delete_suffix(",").strip }.reject(&:empty?)
|
|
28
|
+
end
|
|
18
29
|
|
|
19
30
|
def [](key)
|
|
20
31
|
return nil unless kv?
|
|
@@ -35,6 +46,8 @@ module RailsHttpLab
|
|
|
35
46
|
def to_h
|
|
36
47
|
if kv?
|
|
37
48
|
pairs.to_h
|
|
49
|
+
elsif list?
|
|
50
|
+
{ items: items }
|
|
38
51
|
else
|
|
39
52
|
{ raw: raw }
|
|
40
53
|
end
|
|
@@ -5,9 +5,10 @@ module RailsHttpLab
|
|
|
5
5
|
module Bruno
|
|
6
6
|
# Parses Bruno .bru files into a Document of ordered Blocks.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
# - :kv
|
|
10
|
-
# - :
|
|
8
|
+
# Three content modes:
|
|
9
|
+
# - :kv — every line is " key: value" (2-space indent), one pair per line.
|
|
10
|
+
# - :list — `name [ ... ]` (e.g. `vars:secret`), body preserved verbatim.
|
|
11
|
+
# - :raw — opaque body, preserved verbatim. Used for body:json, body:text,
|
|
11
12
|
# body:xml, body:graphql, body:graphql:vars, body:sparql,
|
|
12
13
|
# script:pre-request, script:post-response, tests, docs.
|
|
13
14
|
#
|
|
@@ -29,6 +30,8 @@ module RailsHttpLab
|
|
|
29
30
|
|
|
30
31
|
# name = letters/digits/_/- with optional colon-delimited variants
|
|
31
32
|
BLOCK_OPEN_RE = /\A([a-zA-Z][\w-]*(?::[\w-]+)*)\s*\{\s*\z/
|
|
33
|
+
# List blocks, e.g. the `vars:secret [ ... ]` Bruno writes in environments.
|
|
34
|
+
LIST_OPEN_RE = /\A([a-zA-Z][\w-]*(?::[\w-]+)*)\s*\[\s*\z/
|
|
32
35
|
|
|
33
36
|
def self.parse(source)
|
|
34
37
|
new(source).parse
|
|
@@ -67,6 +70,9 @@ module RailsHttpLab
|
|
|
67
70
|
else
|
|
68
71
|
blocks << parse_kv_block(name)
|
|
69
72
|
end
|
|
73
|
+
elsif (m = line.match(LIST_OPEN_RE))
|
|
74
|
+
@i += 1
|
|
75
|
+
blocks << parse_list_block(m[1])
|
|
70
76
|
else
|
|
71
77
|
# If we got here we hit something we don't understand. To stay forgiving,
|
|
72
78
|
# skip it; alternatively raise. We raise so callers know their file is off.
|
|
@@ -159,6 +165,20 @@ module RailsHttpLab
|
|
|
159
165
|
in_triple || depth > 0
|
|
160
166
|
end
|
|
161
167
|
|
|
168
|
+
# Reads until a line that is exactly "]" (Bruno's convention). Item lines
|
|
169
|
+
# are kept verbatim so the block round-trips byte-for-byte.
|
|
170
|
+
def parse_list_block(name)
|
|
171
|
+
content_lines = []
|
|
172
|
+
while @i < @lines.length
|
|
173
|
+
line = @lines[@i]
|
|
174
|
+
@i += 1
|
|
175
|
+
return Block.new(name: name, mode: :list, raw: content_lines.join("\n")) if line.strip == "]"
|
|
176
|
+
content_lines << line
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raise ParseError, "unterminated list block #{name.inspect} (missing closing ']')"
|
|
180
|
+
end
|
|
181
|
+
|
|
162
182
|
# Counts braces to find matching '}'. Body content can include arbitrary braces
|
|
163
183
|
# (JSON objects, JS blocks, etc.) so we can't rely on indentation alone.
|
|
164
184
|
def parse_raw_block(name)
|
|
@@ -42,7 +42,8 @@ module RailsHttpLab
|
|
|
42
42
|
def render_block(block)
|
|
43
43
|
case block.mode
|
|
44
44
|
when :kv then render_kv(block)
|
|
45
|
-
when :raw
|
|
45
|
+
when :raw then render_raw(block)
|
|
46
|
+
when :list then render_raw(block, open: "[", close: "]")
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -55,12 +56,12 @@ module RailsHttpLab
|
|
|
55
56
|
lines
|
|
56
57
|
end
|
|
57
58
|
|
|
58
|
-
def render_raw(block)
|
|
59
|
+
def render_raw(block, open: "{", close: "}")
|
|
59
60
|
body = block.raw.to_s
|
|
60
|
-
out = +"#{block.name} {\n"
|
|
61
|
+
out = +"#{block.name} #{open}\n"
|
|
61
62
|
out << body
|
|
62
63
|
out << "\n" unless body.empty? || body.end_with?("\n")
|
|
63
|
-
out << "}\n"
|
|
64
|
+
out << "#{close}\n"
|
|
64
65
|
out
|
|
65
66
|
end
|
|
66
67
|
end
|
|
@@ -43,8 +43,15 @@ module RailsHttpLab
|
|
|
43
43
|
body_kind = verb_block["body"].to_s
|
|
44
44
|
auth_kind = verb_block["auth"].to_s
|
|
45
45
|
|
|
46
|
+
unresolved = url.scan(/\{\{\s*([^{}]+?)\s*\}\}/).flatten.uniq
|
|
47
|
+
unless unresolved.empty?
|
|
48
|
+
names = unresolved.map { |n| "{{#{n}}}" }.join(", ")
|
|
49
|
+
raise URI::InvalidURIError,
|
|
50
|
+
"unresolved variable #{names} — select an environment that defines it"
|
|
51
|
+
end
|
|
52
|
+
|
|
46
53
|
uri = URI.parse(url)
|
|
47
|
-
merge_query!(uri)
|
|
54
|
+
merge_query!(uri, auth_kind)
|
|
48
55
|
|
|
49
56
|
klass = VERBS.fetch(verb_block.name)
|
|
50
57
|
request = klass.new(uri.request_uri)
|
|
@@ -103,16 +110,42 @@ module RailsHttpLab
|
|
|
103
110
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
104
111
|
end
|
|
105
112
|
|
|
106
|
-
|
|
113
|
+
# Bruno keeps the URL's query string and the `params:query` block in
|
|
114
|
+
# sync, so the same params usually appear in both. Keys present in the
|
|
115
|
+
# block (or added by apikey auth) replace the URL's occurrences of those
|
|
116
|
+
# keys instead of being appended twice; other URL params are kept as-is.
|
|
117
|
+
def merge_query!(uri, auth_kind)
|
|
118
|
+
extra = []
|
|
119
|
+
|
|
107
120
|
params_block = @doc.block("params:query") || @doc.block("query")
|
|
108
|
-
|
|
121
|
+
if params_block&.kv?
|
|
122
|
+
params_block.pairs.each do |k, v|
|
|
123
|
+
next if k.start_with?("~")
|
|
124
|
+
extra << [k, @resolver.resolve(v.to_s)]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if auth_kind == "apikey" && (auth = @doc.block("auth:apikey")) && auth["placement"].to_s == "queryparams"
|
|
129
|
+
key = @resolver.resolve(auth["key"].to_s)
|
|
130
|
+
extra << [key, @resolver.resolve(auth["value"].to_s)] unless key.empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
return if extra.empty?
|
|
109
134
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
135
|
+
overridden = extra.map(&:first)
|
|
136
|
+
kept = uri.query.to_s.split("&").reject do |part|
|
|
137
|
+
part.empty? || overridden.include?(query_key(part))
|
|
138
|
+
end
|
|
139
|
+
added = extra.map do |k, v|
|
|
140
|
+
"#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}"
|
|
113
141
|
end
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
uri.query = (kept + added).join("&")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def query_key(part)
|
|
146
|
+
URI.decode_www_form_component(part.split("=", 2).first)
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
part.split("=", 2).first
|
|
116
149
|
end
|
|
117
150
|
|
|
118
151
|
def apply_headers!(request)
|
|
@@ -138,13 +171,8 @@ module RailsHttpLab
|
|
|
138
171
|
return unless block
|
|
139
172
|
key = @resolver.resolve(block["key"].to_s)
|
|
140
173
|
val = @resolver.resolve(block["value"].to_s)
|
|
141
|
-
placement
|
|
142
|
-
|
|
143
|
-
uri = request.uri || URI.parse("")
|
|
144
|
-
# placement in query is handled at URL level; skip here
|
|
145
|
-
else
|
|
146
|
-
request[key] = val unless key.empty?
|
|
147
|
-
end
|
|
174
|
+
# placement "queryparams" is applied to the URL in merge_query!
|
|
175
|
+
request[key] = val unless key.empty? || block["placement"].to_s == "queryparams"
|
|
148
176
|
end
|
|
149
177
|
end
|
|
150
178
|
|