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)