http.bend

cswap-bend-cli@0.1.0.0 · 206 lines · 33 definitions

1import Base
2
3# HTTPS through curl. Bend 2 has no TLS effect, so every request is a curl
4# run whose whole config (URL, headers, body) arrives on stdin through
5# `--config -`: a bearer token never appears in argv or the process list.
6
7def quote.pick(esc: Bool, c: Char, rest: String) -> String:
8  match esc:
9    case True{}:
10      SCon{'\\', SCon{c, rest}}
11    case False{}:
12      SCon{c, rest}
13
14def quote.char(+c: Char, rest: String) -> String:
15  quote.pick(Char.is_eq(c, '\\') || Char.is_eq(c, '"'), c, rest)
16
17# The body of a quoted curl config value: `\` and `"` escaped.
18def quote(s: String) -> String:
19  match s:
20    case SNil{}:
21      SNil{}
22    case SCon{h, t}:
23      quote.char(h, quote(t))
24
25def line(key: String, value: String) -> String:
26  key ++ " = \"" ++ quote(value) ++ "\"\n"
27
28def header.line(kv: String & String) -> String:
29  (k, v) = kv
30  line("header", k ++ ": " ++ v)
31
32def headers(hs: List<(String & String)>) -> String:
33  match hs:
34    case Nil{}:
35      ""
36    case Con{kv, t}:
37      header.line(kv) ++ headers(t)
38
39def body.pick(empty: Bool, body: String) -> String:
40  match empty:
41    case True{}:
42      ""
43    case False{}:
44      line("data-raw", body)
45
46def body.line(+body: String) -> String:
47  body.pick(String.is_empty(body), body)
48
49# curl config for one request. `dump-header = -` puts the response headers on
50# stdout ahead of the body; `write-out` appends the HTTP status as the last
51# line. An empty body sends no data line (a GET stays a GET).
52def config(method: String, url: String, hs: List<(String & String)>, body: String, timeout_s: U32) -> String:
53  line("url", url)
54    ++ line("request", method)
55    ++ "silent\nshow-error\ndump-header = -\n"
56    ++ line("max-time", U32.show(timeout_s))
57    ++ line("write-out", "\\n%{http_code}")
58    ++ headers(hs)
59    ++ body.line(body)
60
61# Header lookup, case-insensitive on the name. `name` must be lowercase and
62# end with `:`.
63def header.pick(hit: Bool, +name: String, +line: String) -> Maybe<&2, String>:
64  match hit:
65    case True{}:
66      Some{String.trim(String.drop(line, String.length(name)))}
67    case False{}:
68      None{}
69
70def header.match(+name: String, +line: String) -> Maybe<&2, String>:
71  header.pick(String.starts_with(String.to_lower(line), name), name, line)
72
73def header.find(+name: String, ls: List<&2, String>, found: Maybe<&2, String>) -> Maybe<&2, String>:
74  match ls found:
75    case _ Some{v}:
76      Some{v}
77    case Nil{} None{}:
78      None{}
79    case Con{h, t} None{}:
80      header.find(name, t, header.match(name, h))
81
82def header(+name: String, raw: String) -> Maybe<&2, String>:
83  header.find(name, String.lines(raw), None{})
84
85# Run one request: answers curl's exit status and (stdout, stderr). stdout is
86# the response headers, the body, then the HTTP status on the last line.
87def run(cfg: String, timeout_ms: U32) -> IO(Result<&1, &1, U32 & String, U32 & (String & String)>):
88  Process.run("curl", ["--config", "-"], cfg, 4194304, timeout_ms)
89
90# ---- reading a response ----------------------------------------------------------
91
92def after_headers.pick(blank: Bool, rest: List<&2, String>, deeper: List<&2, String>) -> List<&2, String>:
93  match blank:
94    case True{}:
95      rest
96    case False{}:
97      deeper
98
99# Lines after the header block (the first blank line).
100def after_headers(ls: List<&2, String>) -> List<&2, String>:
101  match ls:
102    case Nil{}:
103      []
104    case Con{+h, +t}:
105      after_headers.pick(String.is_empty(String.trim(h)), t, after_headers(t))
106
107def last_line.pick(m: Maybe<&2, String>) -> String:
108  match m:
109    case None{}:
110      ""
111    case Some{s}:
112      s
113
114# The HTTP status `config` asked curl to append as the last line; 0 when absent.
115def status(raw: String) -> U32:
116  Maybe.default(&2, U32, U32.read(String.trim(last_line.pick(List.last(&2, String, String.lines(raw))))), 0)
117
118def body.of(+ls: List<&2, String>) -> String:
119  String.join(List.take(&2, String, ls, Nat.sub(List.length(&2, String, ls), 1n)), "\n")
120
121# The body: what lies between the headers and the status line.
122def body(raw: String) -> String:
123  body.of(after_headers(String.lines(raw)))
124
125# ---- test seam -------------------------------------------------------------
126
127def rewrite.pick(live: Bool, base: String, +url: String) -> String:
128  match live:
129    case True{}:
130      base ++ "/" ++ String.drop(url, 8n)
131    case False{}:
132      url
133
134# With CSWAP_TEST_HTTP_BASE set (`base` non-empty), `https://host/path` goes to
135# `base/host/path`: plain http, same headers and body (contract section 5).
136def rewrite(+base: String, +url: String) -> String:
137  rewrite.pick(Bool.not(String.is_empty(base)) && String.starts_with(url, "https://"), base, url)
138
139def base.pick(r: Result<&1, &1, U32 & String, String>) -> String:
140  match r:
141    case Fail{e}:
142      ""
143    case Done{v}:
144      v
145
146# The test base URL, or "" when unset.
147def test_base() -> IO(String):
148  IO.bind(Result<&1, &1, U32 & String, String>, String, IO.get_env("CSWAP_TEST_HTTP_BASE"), r => IO.pure(String, base.pick(r)))
149
150# ---- one request, parsed ---------------------------------------------------
151
152# status, the raw header block (one line each, `\n` ended), the body.
153type Reply is Data:
154  Reply{status: U32, head: String, body: String}
155
156def head.pick(blank: Bool, h: String, rest: String) -> String:
157  match blank:
158    case True{}:
159      ""
160    case False{}:
161      h ++ "\n" ++ rest
162
163# The header lines: everything before the first blank line.
164def head.of(ls: List<&2, String>) -> String:
165  match ls:
166    case Nil{}:
167      ""
168    case Con{+h, t}:
169      head.pick(String.is_empty(String.trim(h)), h, head.of(t))
170
171def reply.ok(+out: String) -> Result<&2, &2, String, Reply>:
172  Done{Reply{status(out), head.of(String.lines(out)), body(out)}}
173
174# curl exit 28 is its own timeout; anything else that fails is the network.
175def reply.exit(ok: Bool, timed_out: Bool, out: String) -> Result<&2, &2, String, Reply>:
176  match ok timed_out:
177    case True{} _:
178      reply.ok(out)
179    case False{} True{}:
180      Fail{"timeout"}
181    case False{} False{}:
182      Fail{"network"}
183
184def reply.of2(+st: U32, oe: String & String) -> Result<&2, &2, String, Reply>:
185  (o, e) = oe
186  reply.exit(U32.is_eq(st, 0), U32.is_eq(st, 28), o)
187
188def reply.of(r: U32 & (String & String)) -> Result<&2, &2, String, Reply>:
189  (st, oe) = r
190  reply.of2(st, oe)
191
192def reply(r: Result<&1, &1, U32 & String, U32 & (String & String)>) -> Result<&2, &2, String, Reply>:
193  match r:
194    case Fail{e}:
195      Fail{"network"}
196    case Done{v}:
197      reply.of(v)
198
199# One request through curl (config on stdin, so the token never reaches argv),
200# with the test seam applied. Fail carries the error kind: `timeout` or
201# `network`.
202def request(method: String, url: String, hs: List<(String & String)>, body: String, +timeout_s: U32) -> IO(Result<&2, &2, String, Reply>):
203  IO.bind(String, Result<&2, &2, String, Reply>, test_base(),
204    base => IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, Result<&2, &2, String, Reply>,
205      run(config(method, rewrite(base, url), hs, body, timeout_s), (timeout_s * 1000 + 3000 : U32)),
206      r => IO.pure(Result<&2, &2, String, Reply>, reply(r))))