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