main.bend
emerging-ezhttp@0.19.0.0 · 356 lines · 0 definitions
1# ezhttp: HTTP/1.1 client and server for Bend 2, with auth, cookie and CORS helpers.
2#
3# Shared message types, BYO JSON via encode/decode. TLS for the client stays
4# at the wire/runtime layer.
5import Base
6import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes
7import emerging-ezhttp@0.19.0.0/src/url.bend as Url
8import emerging-ezhttp@0.19.0.0/src/pct.bend as Pct
9import emerging-ezhttp@0.19.0.0/src/http.bend as Http
10import emerging-ezhttp@0.19.0.0/src/body.bend as Body
11import emerging-ezhttp@0.19.0.0/src/form.bend as Form
12import emerging-ezhttp@0.19.0.0/src/client.bend as Client
13import emerging-ezhttp@0.19.0.0/src/server.bend as Server
14import emerging-ezhttp@0.19.0.0/src/cookie.bend as Cookie
15import emerging-ezhttp@0.19.0.0/src/cache.bend as Cache
16import emerging-ezhttp@0.19.0.0/src/cors.bend as Cors
17import emerging-ezhttp@0.19.0.0/src/html.bend as Html
18import emerging-ezhttp@0.19.0.0/src/qs.bend as Qs
19import emerging-ezhttp@0.19.0.0/src/unescape.bend as Unescape
20import emerging-ezhttp@0.19.0.0/src/echo.bend as Echo
21
22# URL parse re-export
23def parse_url(url: String) -> Url.Loc: # noqa: L001 re-export of Url.parse
24 Url.parse(url)
25
26# percent-encode a path (RFC 3986 §2.1), as Python's urllib.parse.quote(text):
27# unreserved characters and "/" stay, every other character becomes %XX per
28# UTF-8 byte, upper-case hex (SPEC.md HTTP-URL)
29def quote_path(text: String) -> String: # noqa: L001 re-export of Pct.quote (the path form)
30 Pct.quote(True{}, text)
31
32# percent-encode a path segment, query key or value (RFC 3986 §2.1), as
33# urllib.parse.quote(text, safe=""): like quote_path, but "/" becomes %2F
34def quote_component(text: String) -> String: # noqa: L001 re-export of Pct.quote (the component form)
35 Pct.quote(False{}, text)
36
37# percent-decode a string as urllib.parse.unquote: %XX escapes (either case)
38# become bytes, decoded as UTF-8 with U+FFFD for bytes that are not; an escape
39# that is not two hex digits is kept as written, and characters outside ASCII
40# are kept (SPEC.md HTTP-QS)
41def unquote(text: String) -> String: # noqa: L001 re-export of Qs.unquote
42 Qs.unquote(text)
43
44# percent-decode as urllib.parse.unquote_plus: `+` is a space, then unquote
45def unquote_plus(text: String) -> String: # noqa: L001 re-export of Qs.unquote.plus
46 Qs.unquote.plus(text)
47
48# percent-encode as urllib.parse.quote_plus: as quote_component, but a space
49# is written `+` (so `+` itself is %2B)
50def quote_plus(text: String) -> String: # noqa: L001 re-export of Qs.quote.plus
51 Qs.quote.plus(text)
52
53# a name and a value, reusable: a query field or a cookie, written (k, v)
54def Kv() -> Data: # noqa: L001 re-export of the type Qs.Kv
55 Qs.Kv()
56
57# a query string from name and value pairs, as urllib.parse.urlencode(pairs):
58# `name=value` with both quote_plus'd, joined by `&`, in order
59def urlencode(pairs: List<&2, Qs.Kv()>) -> String: # noqa: L001 re-export of Qs.urlencode
60 Qs.urlencode(pairs)
61
62# a query string's name and value pairs, as
63# urllib.parse.parse_qsl(text, keep_blank_values=True): split on `&`, empty
64# pieces skipped, each cut at its first `=` (a piece without one is a name
65# with an empty value), both unquote_plus'd. Every pair is kept, in order,
66# duplicate names included; query_get reads the last one
67def parse_query(text: String) -> List<&2, Qs.Kv()>: # noqa: L001 re-export of Qs.parse
68 Qs.parse(text)
69
70# the value of the last pair with this name (names compared exactly), none
71# when no pair has it: last-wins, as a dict(parse_qsl(...)) would be
72def query_get(pairs: List<&2, Qs.Kv()>, name: String) -> Maybe<&2, String>: # noqa: L001 re-export of Qs.get
73 Qs.get(pairs, name)
74
75# response parse re-export; `raw` is octets, one character each (SPEC.md
76# HTTP-BODY)
77def parse_response(raw: String) -> Http.Reply: # noqa: L001 re-export of Http.parse
78 Http.parse(raw)
79
80# a response as the client reads it for a request with this method: interim
81# 1xx heads skipped, and no body for HEAD or a 1xx, 204 or 304 status,
82# whatever Content-Length says (SPEC.md HTTP-CLI-9)
83def parse_response_to(method: String, raw: String) -> Http.Reply: # noqa: L001 re-export of Http.parse.to
84 Http.parse.to(method, raw)
85
86# the reason phrase of a response's status line (RFC 9112 §4), read from the
87# head parse_response_to stops at; empty when there is none (SPEC.md
88# HTTP-CLI-11)
89def reason_phrase(raw: String) -> String: # noqa: L001 re-export of Http.phrase.to
90 Http.phrase.to(raw)
91
92# request parse re-export (server inbound)
93def parse_request(raw: String) -> Http.Request: # noqa: L001 re-export of Http.ask
94 Http.ask(raw)
95
96# format a response message (server outbound)
97def format_response(status: U32, headers: List<&2, Http.Header>, body: String) # noqa: L001 re-export of Http.respond
98 -> String:
99 Http.respond(status, headers, body)
100
101# empty request body
102def empty_body() -> Body.Body: # noqa: L001 re-export of Body.body.empty
103 Body.body.empty()
104
105# UTF-8 text request body
106def text_body(text: String) -> Body.Body: # noqa: L001 re-export of Body.body.text
107 Body.body.text(text)
108
109# packed octets request body (bend-kit-bytes Bytes), sent unchanged
110def octets_body(bytes: Bytes.Bytes) -> Body.Body: # noqa: L001 re-export of Body.body.octets
111 Body.body.octets(bytes)
112
113# octets request body from a list of octet values (each 0..255)
114def octets_list_body(bs: List<&2, U32>) -> Body.Body: # noqa: L001 re-export of Body.body.list
115 Body.body.list(bs)
116
117# a multipart/form-data text field (RFC 7578)
118def form_field(name: String, value: String) -> Form.Upload: # noqa: L001 re-export of Form.form.field
119 Form.form.field(name, value)
120
121# a multipart/form-data file part: field name, filename, content type and the
122# file's bytes (packed Bytes, sent unchanged)
123def form_file(name: String, filename: String, ctype: String, # noqa: L001 re-export of Form.form.file
124 bytes: Bytes.Bytes) -> Form.Upload:
125 Form.form.file(name, filename, ctype, bytes)
126
127# a multipart/form-data body (RFC 7578) with this boundary: the Content-Type
128# field value to send and an octet body, or why the boundary cannot be used
129# (SPEC.md HTTP-FORM)
130def form_body(boundary: String, parts: List<&1, Form.Upload>) -> Form.Form: # noqa: L001 re-export of Form.form.body
131 Form.form.body(boundary, parts)
132
133# BYO JSON (or any codec): encode a value with the caller's to_text
134def encode(~A: Data, ~to_text: A -> String, value: A) -> Body.Body: # noqa: L001 re-export of Body.body.encode
135 Body.body.encode(A, to_text, value)
136
137# BYO JSON (or any codec): decode body text with the caller's from_text
138def decode( # noqa: L001 re-export of Body.body.decode
139 ~A: Data,
140 ~from_text: String -> Maybe<&2, A>,
141 text: String
142) -> Maybe<&2, A>:
143 Body.body.decode(A, from_text, text)
144
145# Bearer token header helper
146def bearer(token: String) -> Http.Header: # noqa: L001 re-export of client.bearer = Auth.auth.bearer
147 Client.client.bearer(token)
148
149# Basic credentials header (RFC 7617)
150def basic(user: String, password: String) -> Http.Header: # noqa: L001 re-export of client.basic = Auth.auth.basic
151 Client.client.basic(user, password)
152
153# Set-Cookie header from a parsed cookie (RFC 6265)
154def set_cookie(cookie: Cookie.Cookie) -> Http.Header: # noqa: L001 re-export of Cookie.cookie.set
155 Cookie.cookie.set(cookie)
156
157# parse one Set-Cookie field value
158def parse_cookie(line: String) -> Maybe<&2, Cookie.Cookie>: # noqa: L001 re-export of Cookie.cookie.parse
159 Cookie.cookie.parse(line)
160
161# Cookie request header for a list of cookies
162def cookie_header(cs: List<&2, Cookie.Cookie>) -> Http.Header: # noqa: L001 re-export of Cookie.cookie.request
163 Cookie.cookie.request(cs)
164
165# parse a Cache-Control field value (RFC 9111)
166def cache_control(value: String) -> Cache.Cc: # noqa: L001 re-export of Cache.cache.parse
167 Cache.cache.parse(value)
168
169# freshness of a Cache-Control value at an age in seconds
170def fresh(value: String, age: Nat) -> Bool: # noqa: L001 Cache.cache.fresh of Cache.cache.parse
171 Cache.cache.fresh(Cache.cache.parse(value), age)
172
173# CORS preflight or simple headers on a handler reply
174def cors_reply( # noqa: L001 re-export of Cors.cors.on
175 cfg: Cors.Cfg,
176 req: Http.Request,
177 reply: Http.Reply
178) -> Http.Reply:
179 Cors.cors.on(cfg, req, reply)
180
181# escape text for HTML as markupsafe and Jinja's autoescape do: & < > " ' as
182# & < > " ', every other character as itself
183def html_escape(text: String) -> String: # noqa: L001 re-export of Html.escape
184 Html.escape(text)
185
186# HTML character references back to characters, as Python's html.unescape:
187# decimal and hex numeric references (with the HTML Standard's replacements)
188# and the HTML Standard's 2231 named references, the legacy ones also without
189# `;`; anything that is not a reference is kept as written (SPEC.md HTTP-REF)
190def html_unescape(text: String) -> String: # noqa: L001 re-export of Unescape.unescape
191 Unescape.unescape(text)
192
193# the Cookie header value to send back after these Set-Cookie field values
194# (RFC 6265 §5.2, §5.4): each one's name=value, attributes ignored; a later
195# cookie with the same name replaces the earlier one's value in its place;
196# `name=value` pairs joined by `; ` (SPEC.md HTTP-CK)
197def cookie_from_set_cookies(values: List<&2, String>) -> String: # noqa: L001 re-export of Echo.echo
198 Echo.echo(values)
199
200# the same, from a response's header fields (its Set-Cookie fields, in order)
201def cookie_from_headers(headers: List<&2, Http.Header>) -> String: # noqa: L001 Echo.echo of Echo.values
202 Echo.echo(Echo.values(headers))
203
204# GET with no extra headers
205def http.get(url: String) -> IO(Client.Response):
206 Client.client.get(url, [])
207
208# GET with headers
209def http.get_with(url: String, headers: List<&2, Http.Header>)
210 -> IO(Client.Response):
211 Client.client.get(url, headers)
212
213# GET with headers and a timeout in milliseconds that bounds connecting,
214# sending and receiving; with `follow`, redirects are followed (GET only, at
215# most 10 hops, see src/redirect.bend)
216def http.get_opts(
217 url: String,
218 headers: List<&2, Http.Header>,
219 timeout_ms: U32,
220 follow: Bool
221) -> IO(Client.Response):
222 Client.client.get.opts(url, headers, timeout_ms, follow)
223
224# a request with any method, headers, a body and a timeout in milliseconds
225# that bounds connecting, sending and receiving; with `follow`, a GET follows
226# redirects (at most 10 hops, see src/redirect.bend) and every other method
227# answers a redirect as its response
228def http.request_opts(
229 method: String,
230 url: String,
231 headers: List<&2, Http.Header>,
232 body: Body.Body,
233 timeout_ms: U32,
234 follow: Bool
235) -> IO(Client.Response):
236 Client.client.request.opts(method, url, headers, body, timeout_ms, follow)
237
238# POST with headers, a body and a timeout in milliseconds; redirects are not
239# followed
240def http.post_opts(
241 url: String,
242 headers: List<&2, Http.Header>,
243 body: Body.Body,
244 timeout_ms: U32
245) -> IO(Client.Response):
246 http.request_opts("POST", url, headers, body, timeout_ms, False{})
247
248# PUT with headers, a body and a timeout in milliseconds; redirects are not
249# followed
250def http.put_opts(
251 url: String,
252 headers: List<&2, Http.Header>,
253 body: Body.Body,
254 timeout_ms: U32
255) -> IO(Client.Response):
256 http.request_opts("PUT", url, headers, body, timeout_ms, False{})
257
258# DELETE with headers, a body and a timeout in milliseconds; redirects are
259# not followed
260def http.delete_opts(
261 url: String,
262 headers: List<&2, Http.Header>,
263 body: Body.Body,
264 timeout_ms: U32
265) -> IO(Client.Response):
266 http.request_opts("DELETE", url, headers, body, timeout_ms, False{})
267
268# whether a response is an exchange that ran out of time
269def http.timed_out(r: Client.Response) -> Bool:
270 Client.client.timed_out(r)
271
272# HEAD with no extra headers
273def http.head(url: String) -> IO(Client.Response):
274 Client.client.head(url, [])
275
276# HEAD with headers
277def http.head_with(url: String, headers: List<&2, Http.Header>)
278 -> IO(Client.Response):
279 Client.client.head(url, headers)
280
281# POST with a body and no extra headers
282def http.post(url: String, body: Body.Body) -> IO(Client.Response):
283 Client.client.post(url, [], body)
284
285# POST with headers and a body
286def http.post_with(
287 url: String,
288 headers: List<&2, Http.Header>,
289 body: Body.Body
290) -> IO(Client.Response):
291 Client.client.post(url, headers, body)
292
293# PUT with a body and no extra headers
294def http.put(url: String, body: Body.Body) -> IO(Client.Response):
295 Client.client.put(url, [], body)
296
297# PUT with headers and a body
298def http.put_with(url: String, headers: List<&2, Http.Header>, body: Body.Body)
299 -> IO(Client.Response):
300 Client.client.put(url, headers, body)
301
302# DELETE with a body and no extra headers
303def http.delete(url: String, body: Body.Body) -> IO(Client.Response):
304 Client.client.delete(url, [], body)
305
306# DELETE with headers and a body
307def http.delete_with(
308 url: String,
309 headers: List<&2, Http.Header>,
310 body: Body.Body
311) -> IO(Client.Response):
312 Client.client.delete(url, headers, body)
313
314# OPTIONS with no extra headers
315def http.options(url: String) -> IO(Client.Response):
316 Client.client.options(url, [])
317
318# OPTIONS with headers
319def http.options_with(url: String, headers: List<&2, Http.Header>)
320 -> IO(Client.Response):
321 Client.client.options(url, headers)
322
323# listen on host:port and serve up to `limit` connections
324def http.serve(
325 ~handle: Http.Request -> Http.Reply,
326 host: String,
327 port: U32,
328 limit: U32
329) -> IO(Unit):
330 Server.server.serve(handle, host, port, limit)
331
332# listen on host:port and serve up to `limit` connections with a handler that
333# does IO; a failed connection is logged and the next one is served
334def http.serve_io(
335 ~handle: Http.Request -> IO(Http.Reply),
336 host: String,
337 port: U32,
338 limit: U32
339) -> IO(Unit):
340 Server.server.serve_io(handle, host, port, limit)
341
342# listen on host:port and serve with an IO handler until the process stops
343@unsafe def http.serve_forever(
344 ~handle: Http.Request -> IO(Http.Reply),
345 host: String,
346 port: U32
347) -> IO(Unit):
348 Server.server.serve_forever(handle, host, port)
349
350# listen on host:port, serve one connection, then stop
351def http.serve_once(
352 ~handle: Http.Request -> Http.Reply,
353 host: String,
354 port: U32
355) -> IO(Unit):
356 Server.server.once(handle, host, port)