hairpin.bend
bend-kit-hairpin@0.1.0.0 · 186 lines · 0 definitions
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
2import Base
3import bend-kit-http@0.23.0.1/http.bend as Http
4import 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.
6import bend-kit-url@0.4.1.0/url.bend as Url
7import bend-kit-json@0.5.0.1/json.bend as Json
8import bend-kit-bytes@0.3.0.0/bytes.bend as Bytes
9
10# base: "" for none. headers: lowercase names. ms: the step timeout. retries: extra tries for idempotent methods.
11type 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
14type 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.
18def new.with(+cap: U32) -> Client:
19 Client{Opts{"", Http.empty(), 30000, Http.ModeFollow{}, 0, None{}}, Http.pool.new.with(cap), None{}}
20
21def new() -> Client:
22 new.with(8)
23
24def 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.
29def 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
33def 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
37def 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
41def 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.
46def 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.
51def 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.
56def jar.of(c: Client) -> Client & Maybe<&2, Http.Jar>:
57 Client{o, p, +j} = c
58 (Client{o, p, j}, j)
59
60def close(c: Client) -> IO(Unit):
61 Client{o, p, j} = c
62 Http.pool.close(p)
63
64def 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.
72def target(+b: String, ref: String) -> Maybe<&2, String>:
73 target.pick(String.is_empty(b), b, ref)
74
75def 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.
84def 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
87def 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<Maybe<&2, Http.Jar>>:
93 now : Time.Instant <- Time.now()
94 return Some{Http.jar.at(jar, now)}
95
96def 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<Http.Pool & Maybe<&2, Http.Jar> & 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.
104def 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<Http.Pool & Maybe<&2, Http.Jar> & 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
113def 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
119def 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.
125def 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<Http.Pool & Maybe<&2, Http.Jar> & 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.
135def 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<Http.Pool & Maybe<&2, Http.Jar> & 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
155def 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
160def 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<Client & Result<&1, &1, Http.Err, Http.Res>>:
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.
173def 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<Client & Result<&1, &1, Http.Err, Http.Res>>:
178 enc : String <- Http.codings()
179 request.go(o, n, enc, p, j, method, target.abs(target(b, url)), merge(h, headers), body)
180
181def 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.
185def 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))