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