main.bend

emerging-ezhttp@0.21.0.0 · 417 lines · 65 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.21.0.0/src/url.bend as Url
8import emerging-ezhttp@0.21.0.0/src/pct.bend as Pct
9import emerging-ezhttp@0.21.0.0/src/http.bend as Http
10import emerging-ezhttp@0.21.0.0/src/body.bend as Body
11import emerging-ezhttp@0.21.0.0/src/form.bend as Form
12import emerging-ezhttp@0.21.0.0/src/client.bend as Client
13import emerging-ezhttp@0.21.0.0/src/server.bend as Server
14import emerging-ezhttp@0.21.0.0/src/cookie.bend as Cookie
15import emerging-ezhttp@0.21.0.0/src/cache.bend as Cache
16import emerging-ezhttp@0.21.0.0/src/cors.bend as Cors
17import emerging-ezhttp@0.21.0.0/src/html.bend as Html
18import emerging-ezhttp@0.21.0.0/src/qs.bend as Qs
19import emerging-ezhttp@0.21.0.0/src/unescape.bend as Unescape
20import emerging-ezhttp@0.21.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# &amp; &lt; &gt; &#34; &#39;, 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# a response body's octets (a Response's `bytes`) decoded as UTF-8, done only
273# when called (SPEC.md HTTP-BODY-4)
274def body_text(bytes: Bytes.Bytes) -> String:  # noqa: L001 re-export of Client.client.text
275  Client.client.text(bytes)
276
277# HEAD with no extra headers
278def http.head(url: String) -> IO(Client.Response):
279  Client.client.head(url, [])
280
281# HEAD with headers
282def http.head_with(url: String, headers: List<&2, Http.Header>)
283  -> IO(Client.Response):
284  Client.client.head(url, headers)
285
286# POST with a body and no extra headers
287def http.post(url: String, body: Body.Body) -> IO(Client.Response):
288  Client.client.post(url, [], body)
289
290# POST with headers and a body
291def http.post_with(
292  url: String,
293  headers: List<&2, Http.Header>,
294  body: Body.Body
295) -> IO(Client.Response):
296  Client.client.post(url, headers, body)
297
298# PUT with a body and no extra headers
299def http.put(url: String, body: Body.Body) -> IO(Client.Response):
300  Client.client.put(url, [], body)
301
302# PUT with headers and a body
303def http.put_with(url: String, headers: List<&2, Http.Header>, body: Body.Body)
304  -> IO(Client.Response):
305  Client.client.put(url, headers, body)
306
307# DELETE with a body and no extra headers
308def http.delete(url: String, body: Body.Body) -> IO(Client.Response):
309  Client.client.delete(url, [], body)
310
311# DELETE with headers and a body
312def http.delete_with(
313  url: String,
314  headers: List<&2, Http.Header>,
315  body: Body.Body
316) -> IO(Client.Response):
317  Client.client.delete(url, headers, body)
318
319# OPTIONS with no extra headers
320def http.options(url: String) -> IO(Client.Response):
321  Client.client.options(url, [])
322
323# OPTIONS with headers
324def http.options_with(url: String, headers: List<&2, Http.Header>)
325  -> IO(Client.Response):
326  Client.client.options(url, headers)
327
328# listen on host:port and serve up to `limit` connections
329def http.serve(
330  ~handle: Http.Request -> Http.Reply,
331  host: String,
332  port: U32,
333  limit: U32
334) -> IO(Unit):
335  Server.server.serve(handle, host, port, limit)
336
337# listen on host:port and serve up to `limit` connections with a handler that
338# does IO; a failed connection is logged and the next one is served
339def http.serve_io(
340  ~handle: Http.Request -> IO(Http.Reply),
341  host: String,
342  port: U32,
343  limit: U32
344) -> IO(Unit):
345  Server.server.serve_io(handle, host, port, limit)
346
347# listen on host:port and serve with an IO handler until the process stops
348@unsafe def http.serve_forever(
349  ~handle: Http.Request -> IO(Http.Reply),
350  host: String,
351  port: U32
352) -> IO(Unit):
353  Server.server.serve_forever(handle, host, port)
354
355# listen on host:port and serve up to `limit` connections, each on its own
356# fork and at most `max` at once (0: no limit), calling a pure handler with
357# `ctx` and the request (Server.Req: text, and its body's octets); returns
358# once every connection is closed (SPEC.md HTTP-SRV, HTTP-TRUST-2)
359def http.serve_with(
360  ~C: Data,
361  ~handle: C -> Server.Req -> Server.Answer,
362  ctx: C,
363  host: String,
364  port: U32,
365  limit: U32,
366  max: U32
367) -> IO(Unit):
368  Server.server.serve_with(~C, handle, ctx, host, port, limit, max)
369
370# the same with a handler that does IO
371def http.serve_io_with(
372  ~C: Data,
373  ~handle: C -> Server.Req -> IO(Server.Answer),
374  ctx: C,
375  host: String,
376  port: U32,
377  limit: U32,
378  max: U32
379) -> IO(Unit):
380  Server.server.serve_io_with(~C, handle, ctx, host, port, limit, max)
381
382# the same with no limit: it serves until the process is stopped
383@unsafe def http.serve_forever_with(
384  ~C: Data,
385  ~handle: C -> Server.Req -> IO(Server.Answer),
386  ctx: C,
387  host: String,
388  port: U32,
389  max: U32
390) -> IO(Unit):
391  Server.server.serve_forever_with(~C, handle, ctx, host, port, max)
392
393# a reply with a text body, written as UTF-8
394def text_reply(  # noqa: L001 constructor of Server.Answer, which holds Bytes
395  status: U32, headers: List<&2, Http.Header>, body: String) -> Server.Answer:
396  Server.TextReply{status, headers, body}
397
398# a reply whose body is packed octets, written unchanged
399def octets_reply(  # noqa: L001 constructor of Server.Answer, which holds Bytes
400  status: U32, headers: List<&2, Http.Header>, bytes: Bytes.Bytes)
401  -> Server.Answer:
402  Server.OctetsReply{status, headers, bytes}
403
404# a reply streamed chunked: each chunk the channel gives is written as it
405# comes, and the body ends when the channel is closed
406def stream_reply(  # noqa: L001 constructor of Server.Answer, which holds a channel
407  status: U32, headers: List<&2, Http.Header>, chunks: Chan(Bytes.Bytes))
408  -> Server.Answer:
409  Server.StreamReply{status, headers, chunks}
410
411# listen on host:port, serve one connection, then stop
412def http.serve_once(
413  ~handle: Http.Request -> Http.Reply,
414  host: String,
415  port: U32
416) -> IO(Unit):
417  Server.server.once(handle, host, port)