1 import 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 7 def 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 14 def 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. 18 def quote(s: String) -> String: 19 match s: 20 case SNil{}: 21 SNil{} 22 case SCon{h, t}: 23 quote.char(h, quote(t)) 24 25 def line(key: String, value: String) -> String: 26 key ++ " = \"" ++ quote(value) ++ "\"\n" 27 28 def header.line(kv: String & String) -> String: 29 (k, v) = kv 30 line("header", k ++ ": " ++ v) 31 32 def 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 39 def body.pick(empty: Bool, body: String) -> String: 40 match empty: 41 case True{}: 42 "" 43 case False{}: 44 line("data-raw", body) 45 46 def 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). 52 def 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 `:`. 63 def 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 70 def header.match(+name: String, +line: String) -> Maybe<&2, String>: 71 header.pick(String.starts_with(String.to_lower(line), name), name, line) 72 73 def 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 82 def 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. 87 def 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 92 def 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). 100 def 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 107 def 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. 115 def 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 118 def 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. 122 def body(raw: String) -> String: 123 body.of(after_headers(String.lines(raw))) 124 125 # ---- test seam ------------------------------------------------------------- 126 127 def 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). 136 def rewrite(+base: String, +url: String) -> String: 137 rewrite.pick(Bool.not(String.is_empty(base)) && String.starts_with(url, "https://"), base, url) 138 139 def 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. 147 def 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. 153 type Reply is Data: 154 Reply{status: U32, head: String, body: String} 155 156 def 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. 164 def 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 171 def 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. 175 def 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 184 def 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 188 def reply.of(r: U32 & (String & String)) -> Result<&2, &2, String, Reply>: 189 (st, oe) = r 190 reply.of2(st, oe) 191 192 def 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`. 202 def 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))))