src/client.bend

emerging-ezhttp@0.21.0.0 · 201 lines · 26 definitions

1# ezhttp/src/client: fetch a URL. URL and HTTP parsing joined to the wire effect.
2# Answers a structured Response (status + headers + body), not a hub-style
3# "0\n…" string contract.
4import Base
5import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes
6import emerging-ezhttp@0.21.0.0/src/url.bend as Url
7import emerging-ezhttp@0.21.0.0/src/http.bend as Http
8import emerging-ezhttp@0.21.0.0/src/body.bend as Body
9import emerging-ezhttp@0.21.0.0/src/wire.bend as Wire
10import emerging-ezhttp@0.21.0.0/src/wirespec.bend as Spec
11import emerging-ezhttp@0.21.0.0/src/redirect.bend as Redirect
12import emerging-ezhttp@0.21.0.0/src/auth.bend as Auth
13import emerging-ezhttp@0.21.0.0/src/qs.bend as Qs
14import emerging-ezhttp@0.21.0.0/src/packed.bend as Packed
15
16# structured client result: transport/parse error, or an HTTP response: its
17# status, its header fields and reason phrase as text (decoded from the octets
18# received as UTF-8, SPEC.md HTTP-BODY-4, HTTP-CLI-11), and its body's octets
19# as they arrived, packed (`client.text` decodes them when asked)
20type Response is Type:  # noqa: L001 the wire effect keeps src/client.bend out of law files (HTTP-TRUST-5)
21  Err{why: String}
22  Response{status: U32, headers: List<&2, Http.Header>, reason: String,
23    bytes: Bytes.Bytes}
24
25# a body's octets decoded as UTF-8 (SPEC.md HTTP-BODY-4)
26def client.text(b: Bytes.Bytes) -> String:
27  Qs.octets.text(Bytes.to_string(b))
28
29# a response read off its octets as a client Response, its head's octet text
30# decoded
31def from.got(g: Packed.Got) -> Response:
32  match g:
33    case Packed.Torn{why}:
34      Err{why}
35    case Packed.Got{status, headers, reason, body}:
36      Response{status, Qs.octets.headers(headers), Qs.octets.text(reason), body}
37
38# an exchange's answer as a client Response, read by the request's method
39# (packed.bend packed.reply: interim 1xx heads skipped, no body for HEAD, 1xx,
40# 204 or 304); a failed exchange keeps its reason (wirespec.bend wire.failed)
41def http.answer(+method: String, a: Wire.Answer) -> Response:
42  match a:
43    case Wire.Failed{answer}:
44      Err{Spec.wire.failed(answer)}
45    case Wire.Received{octets}:
46      from.got(Packed.packed.reply(method, octets))
47
48# an exchange as wirespec.bend wrote it, with the body's octets after it:
49# sent and read by its own method, or the URL's reason
50def http.send(ex: Spec.Exchange, tail: Bytes.Bytes) -> IO(Response):
51  match ex:
52    case Spec.NoUrl{why}:
53      IO.pure(Response, Err{why})
54    case Spec.Exchange{secure, host, port, +method, req}:
55      do IO<Response>:
56        a : Wire.Answer <- Wire.wire.talk(secure, host, port, method, req, tail)
57        IO.pure(Response, http.answer(method, a))
58
59# an exchange for a body of this shape, sent with the body's octets
60def http.loc.shaped(method: String, l: Url.Loc,
61  headers: List<&2, Http.Header>, r: Body.Shape & Bytes.Bytes)
62  -> IO(Response):
63  (shape, tail) = r
64  http.send(Spec.wire.exchange.body(method, l, headers, shape), tail)
65
66# a URL that came apart (or not) into an HTTP exchange with this body
67def http.loc(method: String, l: Url.Loc, headers: List<&2, Http.Header>,
68  body: Body.Body) -> IO(Response):
69  http.loc.shaped(method, l, headers, Body.body.shape(body))
70
71# an exchange as wirespec.bend wrote it, bounded by a timeout, sent and read
72# by its own method
73def http.send.timed(ms: U32, ex: Spec.Exchange, tail: Bytes.Bytes)
74  -> IO(Response):
75  match ex:
76    case Spec.NoUrl{why}:
77      IO.pure(Response, Err{why})
78    case Spec.Exchange{secure, host, port, +method, req}:
79      do IO<Response>:
80        a : Wire.Answer <- Wire.wire.talk.timed(secure, ms, host, port, method,
81          req, tail)
82        IO.pure(Response, http.answer(method, a))
83
84# a timed exchange for a body of this shape
85def client.timed.shaped(method: String, url: String,
86  headers: List<&2, Http.Header>, ms: U32, r: Body.Shape & Bytes.Bytes)
87  -> IO(Response):
88  (shape, tail) = r
89  http.send.timed(ms, Spec.wire.exchange.body(method, Url.parse(url), headers,
90    shape), tail)
91
92# a request with this method, headers and body, bounded by a timeout in
93# milliseconds
94def client.timed(method: String, url: String, headers: List<&2, Http.Header>,
95  body: Body.Body, ms: U32) -> IO(Response):
96  client.timed.shaped(method, url, headers, ms, Body.body.shape(body))
97
98# GET bounded by a timeout in milliseconds
99def client.get.timed(url: String, headers: List<&2, Http.Header>, ms: U32)
100  -> IO(Response):
101  client.timed("GET", url, headers, Body.Empty{}, ms)
102
103# the next hop after a response (redirect.bend), and the response; a failed
104# exchange stops
105def client.hop(left: Nat, url: String, r: Response) -> Redirect.Hop & Response:
106  match r:
107    case Err{why}:
108      (Redirect.Stop{}, Err{why})
109    case Response{+status, +headers, reason, bytes}:
110      (Redirect.redirect.plan(left, url, status, headers),
111        Response{status, headers, reason, bytes})
112
113# a response, or the GET its hop asks for
114def client.after(p: Redirect.Hop & Response, go: String -> IO(Response))
115  -> IO(Response):
116  (hop, r) = p
117  match hop:
118    case Redirect.Stop{}:
119      IO.pure(Response, r)
120    case Redirect.Go{next}:
121      go(next)
122
123# GET, following redirects while `left` hops remain; `body` goes with this
124# hop only. The answer is the last response, which is a redirect only when
125# the hops ran out or its Location is not one redirect.bend follows
126def client.follow(+left: Nat, +url: String, +headers: List<&2, Http.Header>,
127  body: Body.Body, +ms: U32) -> IO(Response):
128  match left:
129    case 0n:
130      client.timed("GET", url, headers, body, ms)
131    case 1n+p:
132      do IO<Response>:
133        r : Response <- client.timed("GET", url, headers, body, ms)
134        client.after(client.hop(left, url, r),
135          next => client.follow(p, next, headers, Body.Empty{}, ms))
136
137# a request once it is known whether it follows redirects
138def client.request.go(go: Bool, method: String, url: String,
139  headers: List<&2, Http.Header>, body: Body.Body, ms: U32) -> IO(Response):
140  match go:
141    case True{}:
142      client.follow(Redirect.redirect.max(), url, headers, body, ms)
143    case False{}:
144      client.timed(method, url, headers, body, ms)
145
146# a request with a method, headers, body and timeout; it follows redirects
147# when `follow` is set and the method is GET (redirect.bend
148# redirect.follows), and answers its first response otherwise
149def client.request.opts(+method: String, url: String,
150  headers: List<&2, Http.Header>, body: Body.Body, ms: U32, follow: Bool)
151  -> IO(Response):
152  client.request.go(Redirect.redirect.follows(method, follow), method, url,
153    headers, body, ms)
154
155# GET with headers and a timeout, following redirects when `follow` is set
156def client.get.opts(url: String, headers: List<&2, Http.Header>, ms: U32,
157  follow: Bool) -> IO(Response):
158  client.request.opts("GET", url, headers, Body.Empty{}, ms, follow)
159
160# whether a response is the failure of an exchange that ran out of time
161def client.timed_out(r: Response) -> Bool:
162  match r:
163    case Err{why}:
164      Spec.wire.timed_out(why)
165    case Response{status, headers, reason, bytes}:
166      False{}
167
168# GET
169def client.get(url: String, headers: List<&2, Http.Header>) -> IO(Response):
170  http.loc("GET", Url.parse(url), headers, Body.Empty{})
171
172# HEAD (RFC 9110 §9.3.2)
173def client.head(url: String, headers: List<&2, Http.Header>) -> IO(Response):
174  http.loc("HEAD", Url.parse(url), headers, Body.Empty{})
175
176# POST
177def client.post(url: String, headers: List<&2, Http.Header>, body: Body.Body)
178  -> IO(Response):
179  http.loc("POST", Url.parse(url), headers, body)
180
181# PUT
182def client.put(url: String, headers: List<&2, Http.Header>, body: Body.Body)
183  -> IO(Response):
184  http.loc("PUT", Url.parse(url), headers, body)
185
186# DELETE
187def client.delete(url: String, headers: List<&2, Http.Header>, body: Body.Body)
188  -> IO(Response):
189  http.loc("DELETE", Url.parse(url), headers, body)
190
191# OPTIONS
192def client.options(url: String, headers: List<&2, Http.Header>) -> IO(Response):
193  http.loc("OPTIONS", Url.parse(url), headers, Body.Empty{})
194
195# Authorization: Bearer … (auth.bend)
196def client.bearer(token: String) -> Http.Header:
197  Auth.auth.bearer(token)
198
199# Authorization: Basic … (auth.bend; RFC 7617, RFC 4648 Base64)
200def client.basic(user: String, password: String) -> Http.Header:
201  Auth.auth.basic(user, password)