1 # Hairpin: an HTTP client with a base URL, default headers, a pool, cookies, mTLS, redirects, and retries. Source: https://github.com/paymog/bend-kit/tree/main/hairpin 2 import Base 3 import bend-kit-http@0.23.0.1/http.bend as Http 4 import bend-kit-time@0.1.0.0/time.bend as Time 5 # By hash, as http imports them: bend-kit-url@0.4.1.0, -json@0.5.0.1, -bytes@0.3.0.0. 6 import 0x1f2d80f53f971b16c6de6a65cb1918ae/url.bend as Url 7 import 0x584fc27920487ceab242392391418d7f/json.bend as Json 8 import 0x49814d83de8f70993a43e1002be29ecd/bytes.bend as Bytes 9 10 # base: "" for none. headers: lowercase names. ms: the step timeout. retries: extra tries for idempotent methods. 11 type Opts is Data: 12 Opts{base: String, headers: Map<&2, List<&2, String>>, ms: U32, mode: Http.Mode, retries: U32, cert: Maybe<&2, Http.Cert>} 13 14 type Client is Type: 15 Client{opts: Opts, pool: Http.Pool, jar: Maybe<&2, Http.Jar>} 16 17 # A pool of up to cap idle sockets per origin, no base, no default headers, 30 s steps, follow redirects, no retries. 18 def new.with(+cap: U32) -> Client: 19 Client{Opts{"", Http.empty(), 30000, Http.ModeFollow{}, 0, None{}}, Http.pool.new.with(cap), None{}} 20 21 def new() -> Client: 22 new.with(8) 23 24 def base(c: Client, url: String) -> Client: 25 Client{Opts{b, h, ms, mode, n, cert}, p, j} = c 26 Client{Opts{url, h, ms, mode, n, cert}, p, j} 27 28 # A default header. It replaces an earlier default of the same name; a request header of that name replaces it. 29 def header(c: Client, k: String, v: String) -> Client: 30 Client{Opts{b, h, ms, mode, n, cert}, p, j} = c 31 Client{Opts{b, Http.set(h, String.to_lower(k), v), ms, mode, n, cert}, p, j} 32 33 def timeout(c: Client, ms: U32) -> Client: 34 Client{Opts{b, h, old, mode, n, cert}, p, j} = c 35 Client{Opts{b, h, ms, mode, n, cert}, p, j} 36 37 def redirect(c: Client, mode: Http.Mode) -> Client: 38 Client{Opts{b, h, ms, old, n, cert}, p, j} = c 39 Client{Opts{b, h, ms, mode, n, cert}, p, j} 40 41 def retry(c: Client, n: U32) -> Client: 42 Client{Opts{b, h, ms, mode, old, cert}, p, j} = c 43 Client{Opts{b, h, ms, mode, n, cert}, p, j} 44 45 # Paths to a PEM client chain and its private key, presented to every HTTPS origin. 46 def cert(c: Client, chain: String, key: String) -> Client: 47 Client{Opts{b, h, ms, mode, n, old}, p, j} = c 48 Client{Opts{b, h, ms, mode, n, Some{Http.Cert{chain, key}}}, p, j} 49 50 # Every request stores and sends cookies through jar. Its clock moves to Time.now() before each try. 51 def jar(c: Client, jar: Http.Jar) -> Client: 52 Client{o, p, old} = c 53 Client{o, p, Some{jar}} 54 55 # The jar, if the client has one. 56 def jar.of(c: Client) -> Client & Maybe<&2, Http.Jar>: 57 Client{o, p, +j} = c 58 (Client{o, p, j}, j) 59 60 def close(c: Client) -> IO(Unit): 61 Client{o, p, j} = c 62 Http.pool.close(p) 63 64 def target.pick(none: Bool, b: String, ref: String) -> Maybe<&2, String>: 65 match none: 66 case True{}: 67 Some{ref} 68 case False{}: 69 Http.resolve(b, ref) 70 71 # ref against the base (RFC 3986 ยง5.2); with no base, ref as given. None when the base is not absolute. 72 def target(+b: String, ref: String) -> Maybe<&2, String>: 73 target.pick(String.is_empty(b), b, ref) 74 75 def target.abs(m: Maybe<&2, String>) -> Maybe<&2, Url.Abs>: 76 match m: 77 case None{}: 78 None{} 79 case Some{s}: 80 Url.absolute(s) 81 82 # The defaults under the request headers, names lowercased; a request header replaces a default of its name. 83 # Host, Connection, Content-Length, and Transfer-Encoding are the client's own, so both sides drop them. 84 def merge(defaults: Map<&2, List<&2, String>>, headers: Map<&2, List<&2, String>>) -> Map<&2, List<&2, String>>: 85 Http.req.put(Map.to_list(&2, List<&2, String>, headers), Http.req.put(Map.to_list(&2, List<&2, String>, defaults), Http.empty())) 86 87 def jar.now(j: Maybe<&2, Http.Jar>) -> IO(Maybe<&2, Http.Jar>): 88 match j: 89 case None{}: 90 IO.pure(Maybe<&2, Http.Jar>, None{}) 91 case Some{jar}: 92 do IO>: 93 now : Time.Instant <- Time.now() 94 return Some{Http.jar.at(jar, now)} 95 96 def once.final(+enc: String, x: Http.Pool & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, Http.Jar>) -> 97 IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>): 98 (p, r, j) = x 99 do IO & Result<&1, &1, Http.Err, Http.Res>>: 100 d : Result<&1, &1, Http.Err, Http.Res> <- Http.fetch.final(enc, r) 101 return (p, j, d) 102 103 # One try: the redirect loop on the pool, with the jar and the certificate, then the body decoded. 104 def once(+ms: U32, +mode: Http.Mode, +cert: Maybe<&2, Http.Cert>, +enc: String, p: Http.Pool, j: Maybe<&2, Http.Jar>, 105 +method: String, +u: Maybe<&2, Url.Abs>, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 106 IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>): 107 do IO & Result<&1, &1, Http.Err, Http.Res>>: 108 now : Maybe<&2, Http.Jar> <- jar.now(j) 109 x : Http.Pool & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, Http.Jar> <- 110 Http.pool.hops(mode, ms, Http.Hops{p, Http.fetch.start(method, headers, body, u, enc), cert, now}) 111 once.final(enc, x) 112 113 def try.kept(p: Http.Pool, j: Maybe<&2, Http.Jar>, body: Bytes.Bytes, 114 r: Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>) -> 115 Http.Pool & Maybe<&2, Http.Jar> & Bytes.Bytes & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>: 116 (res, again) = r 117 (p, j, body, res, again) 118 119 def try.judged(+idem: Bool, body: Bytes.Bytes, x: Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>) -> 120 Http.Pool & Maybe<&2, Http.Jar> & Bytes.Bytes & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>: 121 (p, j, r) = x 122 try.kept(p, j, body, Http.retry.judge(idem, r)) 123 124 # One try of the spare copy. The body comes back for the next try, with Some{Retry-After} when another may help. 125 def try(+o: Opts, +enc: String, +idem: Bool, +method: String, +u: Maybe<&2, Url.Abs>, +headers: Map<&2, List<&2, String>>, 126 p: Http.Pool, j: Maybe<&2, Http.Jar>, r: Bytes.Bytes & Bytes.Bytes) -> 127 IO(Http.Pool & Maybe<&2, Http.Jar> & Bytes.Bytes & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>): 128 Opts{b, h, +ms, +mode, n, +cert} = o 129 (body, spare) = r 130 do IO & Bytes.Bytes & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>>: 131 x : Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res> <- once(ms, mode, cert, enc, p, j, method, u, headers, spare) 132 return try.judged(idem, body, x) 133 134 # left: retries still allowed. n: the retry to wait for next, 0 first. The wait is the fetch.retry policy. 135 def go(left: Nat, +n: Nat, +o: Opts, +enc: String, +idem: Bool, +method: String, +u: Maybe<&2, Url.Abs>, 136 +headers: Map<&2, List<&2, String>>, 137 t: Http.Pool & Maybe<&2, Http.Jar> & Bytes.Bytes & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>) -> 138 IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>): 139 match left: 140 case 0n: 141 (p, j, body, r, m) = t 142 IO.pure(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>, (p, j, r)) 143 case 1n+rest: 144 (p, j, body, r, m) = t 145 match m: 146 case None{}: 147 IO.pure(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>, (p, j, r)) 148 case Some{v}: 149 do IO & Result<&1, &1, Http.Err, Http.Res>>: 150 Http.retry.sleep(v, n) 151 tried : Http.Pool & Maybe<&2, Http.Jar> & Bytes.Bytes & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String> <- 152 try(o, enc, idem, method, u, headers, p, j, Bytes.slice(body, 0, 4294967295)) 153 go(rest, 1n+n, o, enc, idem, method, u, headers, tried) 154 155 def request.done(+o: Opts, x: Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>) -> 156 Client & Result<&1, &1, Http.Err, Http.Res>: 157 (p, j, r) = x 158 (Client{o, p, j}, r) 159 160 def request.go(+o: Opts, +retries: U32, +enc: String, p: Http.Pool, j: Maybe<&2, Http.Jar>, +method: String, 161 +u: Maybe<&2, Url.Abs>, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 162 IO(Client & Result<&1, &1, Http.Err, Http.Res>): 163 +idem = Http.pool.idempotent(method) 164 do IO>: 165 first : Http.Pool & Maybe<&2, Http.Jar> & Bytes.Bytes & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String> <- 166 try(o, enc, idem, method, u, headers, p, j, Bytes.slice(body, 0, 4294967295)) 167 x : Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res> <- go(U32.to_nat(retries), 0n, o, enc, idem, method, u, headers, first) 168 return request.done(o, x) 169 170 # url resolves against the base; headers go over the defaults. Redirects follow the client's mode, at most 20. 171 # GET, HEAD, OPTIONS, TRACE, PUT, and DELETE retry after a connect error, a timeout, or 408, 429, 500, 502, 503, 172 # or 504, as Http.fetch.retry does, on the same pool. When retries run out, the last result comes back. 173 def request(c: Client, +method: String, url: String, headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 174 IO(Client & Result<&1, &1, Http.Err, Http.Res>): 175 Client{+o, p, j} = c 176 Opts{+b, +h, ms, mode, +n, cert} = o 177 do IO>: 178 enc : String <- Http.codings() 179 request.go(o, n, enc, p, j, method, target.abs(target(b, url)), merge(h, headers), body) 180 181 def get(c: Client, url: String) -> IO(Client & Result<&1, &1, Http.Err, Http.Res>): 182 request(c, "GET", url, Http.empty(), Bytes.new(0)) 183 184 # POST v as compact JSON, with content-type: application/json. 185 def post.json(c: Client, url: String, v: Json.Val) -> IO(Client & Result<&1, &1, Http.Err, Http.Res>): 186 request(c, "POST", url, Http.set(Http.empty(), "content-type", "application/json"), Json.encode.bytes(v))