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. 5 import Base 6 import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes 7 import ./src/url.bend as Url 8 import ./src/pct.bend as Pct 9 import ./src/http.bend as Http 10 import ./src/body.bend as Body 11 import ./src/form.bend as Form 12 import ./src/client.bend as Client 13 import ./src/server.bend as Server 14 import ./src/cookie.bend as Cookie 15 import ./src/cache.bend as Cache 16 import ./src/cors.bend as Cors 17 import ./src/html.bend as Html 18 import ./src/qs.bend as Qs 19 import ./src/unescape.bend as Unescape 20 import ./src/echo.bend as Echo 21 22 # URL parse re-export 23 def 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) 29 def 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 34 def 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) 41 def 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 45 def 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) 50 def 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) 54 def 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 59 def 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 67 def 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 72 def 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) 77 def 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) 83 def 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) 89 def 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) 93 def 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) 97 def 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 102 def empty_body() -> Body.Body: # noqa: L001 re-export of Body.body.empty 103 Body.body.empty() 104 105 # UTF-8 text request body 106 def 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 110 def 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) 114 def 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) 118 def 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) 123 def 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) 130 def 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 134 def 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 138 def 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 146 def 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) 150 def 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) 154 def 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 158 def 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 162 def 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) 166 def 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 170 def 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 174 def 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 183 def 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) 190 def 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) 197 def 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) 201 def 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 205 def http.get(url: String) -> IO(Client.Response): 206 Client.client.get(url, []) 207 208 # GET with headers 209 def 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) 216 def 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 228 def 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 240 def 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 250 def 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 260 def 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 269 def 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) 274 def 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 278 def http.head(url: String) -> IO(Client.Response): 279 Client.client.head(url, []) 280 281 # HEAD with headers 282 def 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 287 def http.post(url: String, body: Body.Body) -> IO(Client.Response): 288 Client.client.post(url, [], body) 289 290 # POST with headers and a body 291 def 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 299 def http.put(url: String, body: Body.Body) -> IO(Client.Response): 300 Client.client.put(url, [], body) 301 302 # PUT with headers and a body 303 def 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 308 def http.delete(url: String, body: Body.Body) -> IO(Client.Response): 309 Client.client.delete(url, [], body) 310 311 # DELETE with headers and a body 312 def 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 320 def http.options(url: String) -> IO(Client.Response): 321 Client.client.options(url, []) 322 323 # OPTIONS with headers 324 def 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 329 def 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 339 def 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) 359 def 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 371 def 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 394 def 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 399 def 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 406 def 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 412 def 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)