main.bend
emerging-ezhttp@0.17.0.0 · 346 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.17.0.0/src/url.bend as Url
7import emerging-ezhttp@0.17.0.0/src/pct.bend as Pct
8import emerging-ezhttp@0.17.0.0/src/http.bend as Http
9import emerging-ezhttp@0.17.0.0/src/body.bend as Body
10import emerging-ezhttp@0.17.0.0/src/form.bend as Form
11import emerging-ezhttp@0.17.0.0/src/client.bend as Client
12import emerging-ezhttp@0.17.0.0/src/server.bend as Server
13import emerging-ezhttp@0.17.0.0/src/cookie.bend as Cookie
14import emerging-ezhttp@0.17.0.0/src/cache.bend as Cache
15import emerging-ezhttp@0.17.0.0/src/cors.bend as Cors
16import emerging-ezhttp@0.17.0.0/src/html.bend as Html
17import emerging-ezhttp@0.17.0.0/src/qs.bend as Qs
18import emerging-ezhttp@0.17.0.0/src/unescape.bend as Unescape
19import emerging-ezhttp@0.17.0.0/src/echo.bend as Echo
20
21# URL parse re-export
22def parse_url(url: String) -> Url.Loc:
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:
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:
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:
41 Qs.unquote(text)
42
43# percent-decode as urllib.parse.unquote_plus: `+` is a space, then unquote
44def unquote_plus(text: String) -> String:
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:
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:
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:
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()>:
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>:
72 Qs.get(pairs, name)
73
74# response parse re-export
75def parse_response(raw: String) -> Http.Reply:
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:
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:
88 Http.phrase.to(raw)
89
90# request parse re-export (server inbound)
91def parse_request(raw: String) -> Http.Request:
92 Http.ask(raw)
93
94# format a response message (server outbound)
95def format_response(status: U32, headers: List<&2, Http.Header>, body: String)
96 -> String:
97 Http.respond(status, headers, body)
98
99# empty request body
100def empty_body() -> Body.Body:
101 Body.body.empty()
102
103# UTF-8 text request body
104def text_body(text: String) -> Body.Body:
105 Body.body.text(text)
106
107# octet-list request body
108def octets_body(bs: List<&2, U32>) -> Body.Body:
109 Body.body.octets(bs)
110
111# a multipart/form-data text field (RFC 7578)
112def form_field(name: String, value: String) -> Form.Part:
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,
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:
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:
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(
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:
141 Client.client.bearer(token)
142
143# Basic credentials header (RFC 7617)
144def basic(user: String, password: String) -> Http.Header:
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:
149 Cookie.cookie.set(cookie)
150
151# parse one Set-Cookie field value
152def parse_cookie(line: String) -> Maybe<&2, Cookie.Cookie>:
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:
157 Cookie.cookie.request(cs)
158
159# parse a Cache-Control field value (RFC 9111)
160def cache_control(value: String) -> Cache.Cc:
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:
165 Cache.cache.fresh(Cache.cache.parse(value), age)
166
167# CORS preflight or simple headers on a handler reply
168def cors_reply(cfg: Cors.Cfg, req: Http.Request, reply: Http.Reply) -> Http.Reply:
169 Cors.cors.on(cfg, req, reply)
170
171# escape text for HTML as markupsafe and Jinja's autoescape do: & < > " ' as
172# & < > " ', every other character as itself
173def html_escape(text: String) -> String:
174 Html.escape(text)
175
176# HTML character references back to characters, as Python's html.unescape:
177# decimal and hex numeric references (with the HTML Standard's replacements)
178# and the HTML Standard's 2231 named references, the legacy ones also without
179# `;`; anything that is not a reference is kept as written (SPEC.md HTTP-REF)
180def html_unescape(text: String) -> String:
181 Unescape.unescape(text)
182
183# the Cookie header value to send back after these Set-Cookie field values
184# (RFC 6265 §5.2, §5.4): each one's name=value, attributes ignored; a later
185# cookie with the same name replaces the earlier one's value in its place;
186# `name=value` pairs joined by `; ` (SPEC.md HTTP-CK)
187def cookie_from_set_cookies(values: List<&2, String>) -> String:
188 Echo.echo(values)
189
190# the same, from a response's header fields (its Set-Cookie fields, in order)
191def cookie_from_headers(headers: List<&2, Http.Header>) -> String:
192 Echo.echo(Echo.values(headers))
193
194# GET with no extra headers
195def http.get(url: String) -> IO(Client.Response):
196 Client.client.get(url, [])
197
198# GET with headers
199def http.get_with(url: String, headers: List<&2, Http.Header>)
200 -> IO(Client.Response):
201 Client.client.get(url, headers)
202
203# GET with headers and a timeout in milliseconds that bounds connecting,
204# sending and receiving; with `follow`, redirects are followed (GET only, at
205# most 10 hops, see src/redirect.bend)
206def http.get_opts(
207 url: String,
208 headers: List<&2, Http.Header>,
209 timeout_ms: U32,
210 follow: Bool
211) -> IO(Client.Response):
212 Client.client.get.opts(url, headers, timeout_ms, follow)
213
214# a request with any method, headers, a body and a timeout in milliseconds
215# that bounds connecting, sending and receiving; with `follow`, a GET follows
216# redirects (at most 10 hops, see src/redirect.bend) and every other method
217# answers a redirect as its response
218def http.request_opts(
219 method: String,
220 url: String,
221 headers: List<&2, Http.Header>,
222 body: Body.Body,
223 timeout_ms: U32,
224 follow: Bool
225) -> IO(Client.Response):
226 Client.client.request.opts(method, url, headers, body, timeout_ms, follow)
227
228# POST with headers, a body and a timeout in milliseconds; redirects are not
229# followed
230def http.post_opts(
231 url: String,
232 headers: List<&2, Http.Header>,
233 body: Body.Body,
234 timeout_ms: U32
235) -> IO(Client.Response):
236 http.request_opts("POST", url, headers, body, timeout_ms, False{})
237
238# PUT with headers, a body and a timeout in milliseconds; redirects are not
239# followed
240def http.put_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("PUT", url, headers, body, timeout_ms, False{})
247
248# DELETE with headers, a body and a timeout in milliseconds; redirects are
249# not followed
250def http.delete_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("DELETE", url, headers, body, timeout_ms, False{})
257
258# whether a response is an exchange that ran out of time
259def http.timed_out(r: Client.Response) -> Bool:
260 Client.client.timed_out(r)
261
262# HEAD with no extra headers
263def http.head(url: String) -> IO(Client.Response):
264 Client.client.head(url, [])
265
266# HEAD with headers
267def http.head_with(url: String, headers: List<&2, Http.Header>)
268 -> IO(Client.Response):
269 Client.client.head(url, headers)
270
271# POST with a body and no extra headers
272def http.post(url: String, body: Body.Body) -> IO(Client.Response):
273 Client.client.post(url, [], body)
274
275# POST with headers and a body
276def http.post_with(
277 url: String,
278 headers: List<&2, Http.Header>,
279 body: Body.Body
280) -> IO(Client.Response):
281 Client.client.post(url, headers, body)
282
283# PUT with a body and no extra headers
284def http.put(url: String, body: Body.Body) -> IO(Client.Response):
285 Client.client.put(url, [], body)
286
287# PUT with headers and a body
288def http.put_with(url: String, headers: List<&2, Http.Header>, body: Body.Body)
289 -> IO(Client.Response):
290 Client.client.put(url, headers, body)
291
292# DELETE with a body and no extra headers
293def http.delete(url: String, body: Body.Body) -> IO(Client.Response):
294 Client.client.delete(url, [], body)
295
296# DELETE with headers and a body
297def http.delete_with(
298 url: String,
299 headers: List<&2, Http.Header>,
300 body: Body.Body
301) -> IO(Client.Response):
302 Client.client.delete(url, headers, body)
303
304# OPTIONS with no extra headers
305def http.options(url: String) -> IO(Client.Response):
306 Client.client.options(url, [])
307
308# OPTIONS with headers
309def http.options_with(url: String, headers: List<&2, Http.Header>)
310 -> IO(Client.Response):
311 Client.client.options(url, headers)
312
313# listen on host:port and serve up to `limit` connections
314def http.serve(
315 ~handle: Http.Request -> Http.Reply,
316 host: String,
317 port: U32,
318 limit: U32
319) -> IO(Unit):
320 Server.server.serve(handle, host, port, limit)
321
322# listen on host:port and serve up to `limit` connections with a handler that
323# does IO; a failed connection is logged and the next one is served
324def http.serve_io(
325 ~handle: Http.Request -> IO(Http.Reply),
326 host: String,
327 port: U32,
328 limit: U32
329) -> IO(Unit):
330 Server.server.serve_io(handle, host, port, limit)
331
332# listen on host:port and serve with an IO handler until the process stops
333@unsafe def http.serve_forever(
334 ~handle: Http.Request -> IO(Http.Reply),
335 host: String,
336 port: U32
337) -> IO(Unit):
338 Server.server.serve_forever(handle, host, port)
339
340# listen on host:port, serve one connection, then stop
341def http.serve_once(
342 ~handle: Http.Request -> Http.Reply,
343 host: String,
344 port: U32
345) -> IO(Unit):
346 Server.server.once(handle, host, port)