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 ./src/url.bend as Url 7 import ./src/pct.bend as Pct 8 import ./src/http.bend as Http 9 import ./src/body.bend as Body 10 import ./src/form.bend as Form 11 import ./src/client.bend as Client 12 import ./src/server.bend as Server 13 import ./src/cookie.bend as Cookie 14 import ./src/cache.bend as Cache 15 import ./src/cors.bend as Cors 16 import ./src/html.bend as Html 17 18 # URL parse re-export 19 def parse_url(url: String) -> Url.Loc: 20 Url.parse(url) 21 22 # percent-encode a path (RFC 3986 §2.1), as Python's urllib.parse.quote(text): 23 # unreserved characters and "/" stay, every other character becomes %XX per 24 # UTF-8 byte, upper-case hex (SPEC.md HTTP-URL) 25 def quote_path(text: String) -> String: 26 Pct.quote(True{}, text) 27 28 # percent-encode a path segment, query key or value (RFC 3986 §2.1), as 29 # urllib.parse.quote(text, safe=""): like quote_path, but "/" becomes %2F 30 def quote_component(text: String) -> String: 31 Pct.quote(False{}, text) 32 33 # response parse re-export 34 def parse_response(raw: String) -> Http.Reply: 35 Http.parse(raw) 36 37 # a response as the client reads it for a request with this method: interim 38 # 1xx heads skipped, and no body for HEAD or a 1xx, 204 or 304 status, 39 # whatever Content-Length says (SPEC.md HTTP-CLI-9) 40 def parse_response_to(method: String, raw: String) -> Http.Reply: 41 Http.parse.to(method, raw) 42 43 # request parse re-export (server inbound) 44 def parse_request(raw: String) -> Http.Request: 45 Http.ask(raw) 46 47 # format a response message (server outbound) 48 def format_response(status: U32, headers: List<&2, Http.Header>, body: String) 49 -> String: 50 Http.respond(status, headers, body) 51 52 # empty request body 53 def empty_body() -> Body.Body: 54 Body.body.empty() 55 56 # UTF-8 text request body 57 def text_body(text: String) -> Body.Body: 58 Body.body.text(text) 59 60 # octet-list request body 61 def octets_body(bs: List<&2, U32>) -> Body.Body: 62 Body.body.octets(bs) 63 64 # a multipart/form-data text field (RFC 7578) 65 def form_field(name: String, value: String) -> Form.Part: 66 Form.form.field(name, value) 67 68 # a multipart/form-data file part: field name, filename, content type and the 69 # file's bytes (each 0..255, as File.read_bytes gives them) 70 def form_file(name: String, filename: String, ctype: String, 71 bytes: List<&2, U32>) -> Form.Part: 72 Form.form.file(name, filename, ctype, bytes) 73 74 # a multipart/form-data body (RFC 7578) with this boundary: the Content-Type 75 # field value to send and an octet body, or why the boundary cannot be used 76 # (SPEC.md HTTP-FORM) 77 def form_body(boundary: String, parts: List<&2, Form.Part>) -> Form.Form: 78 Form.form.body(boundary, parts) 79 80 # BYO JSON (or any codec): encode a value with the caller's to_text 81 def encode(~A: Data, ~to_text: A -> String, value: A) -> Body.Body: 82 Body.body.encode(A, to_text, value) 83 84 # BYO JSON (or any codec): decode body text with the caller's from_text 85 def decode( 86 ~A: Data, 87 ~from_text: String -> Maybe<&2, A>, 88 text: String 89 ) -> Maybe<&2, A>: 90 Body.body.decode(A, from_text, text) 91 92 # Bearer token header helper 93 def bearer(token: String) -> Http.Header: 94 Client.client.bearer(token) 95 96 # Basic credentials header (RFC 7617) 97 def basic(user: String, password: String) -> Http.Header: 98 Client.client.basic(user, password) 99 100 # Set-Cookie header from a parsed cookie (RFC 6265) 101 def set_cookie(cookie: Cookie.Cookie) -> Http.Header: 102 Cookie.cookie.set(cookie) 103 104 # parse one Set-Cookie field value 105 def parse_cookie(line: String) -> Maybe<&2, Cookie.Cookie>: 106 Cookie.cookie.parse(line) 107 108 # Cookie request header for a list of cookies 109 def cookie_header(cs: List<&2, Cookie.Cookie>) -> Http.Header: 110 Cookie.cookie.request(cs) 111 112 # parse a Cache-Control field value (RFC 9111) 113 def cache_control(value: String) -> Cache.Cc: 114 Cache.cache.parse(value) 115 116 # freshness of a Cache-Control value at an age in seconds 117 def fresh(value: String, age: Nat) -> Bool: 118 Cache.cache.fresh(Cache.cache.parse(value), age) 119 120 # CORS preflight or simple headers on a handler reply 121 def cors_reply(cfg: Cors.Cfg, req: Http.Request, reply: Http.Reply) -> Http.Reply: 122 Cors.cors.on(cfg, req, reply) 123 124 # escape text for HTML as markupsafe and Jinja's autoescape do: & < > " ' as 125 # & < > " ', every other character as itself 126 def html_escape(text: String) -> String: 127 Html.escape(text) 128 129 # GET with no extra headers 130 def http.get(url: String) -> IO(Client.Response): 131 Client.client.get(url, []) 132 133 # GET with headers 134 def http.get_with(url: String, headers: List<&2, Http.Header>) 135 -> IO(Client.Response): 136 Client.client.get(url, headers) 137 138 # GET with headers and a timeout in milliseconds that bounds connecting, 139 # sending and receiving; with `follow`, redirects are followed (GET only, at 140 # most 10 hops, see src/redirect.bend) 141 def http.get_opts( 142 url: String, 143 headers: List<&2, Http.Header>, 144 timeout_ms: U32, 145 follow: Bool 146 ) -> IO(Client.Response): 147 Client.client.get.opts(url, headers, timeout_ms, follow) 148 149 # a request with any method, headers, a body and a timeout in milliseconds 150 # that bounds connecting, sending and receiving; with `follow`, a GET follows 151 # redirects (at most 10 hops, see src/redirect.bend) and every other method 152 # answers a redirect as its response 153 def http.request_opts( 154 method: String, 155 url: String, 156 headers: List<&2, Http.Header>, 157 body: Body.Body, 158 timeout_ms: U32, 159 follow: Bool 160 ) -> IO(Client.Response): 161 Client.client.request.opts(method, url, headers, body, timeout_ms, follow) 162 163 # POST with headers, a body and a timeout in milliseconds; redirects are not 164 # followed 165 def http.post_opts( 166 url: String, 167 headers: List<&2, Http.Header>, 168 body: Body.Body, 169 timeout_ms: U32 170 ) -> IO(Client.Response): 171 http.request_opts("POST", url, headers, body, timeout_ms, False{}) 172 173 # PUT with headers, a body and a timeout in milliseconds; redirects are not 174 # followed 175 def http.put_opts( 176 url: String, 177 headers: List<&2, Http.Header>, 178 body: Body.Body, 179 timeout_ms: U32 180 ) -> IO(Client.Response): 181 http.request_opts("PUT", url, headers, body, timeout_ms, False{}) 182 183 # DELETE with headers, a body and a timeout in milliseconds; redirects are 184 # not followed 185 def http.delete_opts( 186 url: String, 187 headers: List<&2, Http.Header>, 188 body: Body.Body, 189 timeout_ms: U32 190 ) -> IO(Client.Response): 191 http.request_opts("DELETE", url, headers, body, timeout_ms, False{}) 192 193 # whether a response is an exchange that ran out of time 194 def http.timed_out(r: Client.Response) -> Bool: 195 Client.client.timed_out(r) 196 197 # HEAD with no extra headers 198 def http.head(url: String) -> IO(Client.Response): 199 Client.client.head(url, []) 200 201 # HEAD with headers 202 def http.head_with(url: String, headers: List<&2, Http.Header>) 203 -> IO(Client.Response): 204 Client.client.head(url, headers) 205 206 # POST with a body and no extra headers 207 def http.post(url: String, body: Body.Body) -> IO(Client.Response): 208 Client.client.post(url, [], body) 209 210 # POST with headers and a body 211 def http.post_with( 212 url: String, 213 headers: List<&2, Http.Header>, 214 body: Body.Body 215 ) -> IO(Client.Response): 216 Client.client.post(url, headers, body) 217 218 # PUT with a body and no extra headers 219 def http.put(url: String, body: Body.Body) -> IO(Client.Response): 220 Client.client.put(url, [], body) 221 222 # PUT with headers and a body 223 def http.put_with(url: String, headers: List<&2, Http.Header>, body: Body.Body) 224 -> IO(Client.Response): 225 Client.client.put(url, headers, body) 226 227 # DELETE with a body and no extra headers 228 def http.delete(url: String, body: Body.Body) -> IO(Client.Response): 229 Client.client.delete(url, [], body) 230 231 # DELETE with headers and a body 232 def http.delete_with( 233 url: String, 234 headers: List<&2, Http.Header>, 235 body: Body.Body 236 ) -> IO(Client.Response): 237 Client.client.delete(url, headers, body) 238 239 # OPTIONS with no extra headers 240 def http.options(url: String) -> IO(Client.Response): 241 Client.client.options(url, []) 242 243 # OPTIONS with headers 244 def http.options_with(url: String, headers: List<&2, Http.Header>) 245 -> IO(Client.Response): 246 Client.client.options(url, headers) 247 248 # listen on host:port and serve up to `limit` connections 249 def http.serve( 250 ~handle: Http.Request -> Http.Reply, 251 host: String, 252 port: U32, 253 limit: U32 254 ) -> IO(Unit): 255 Server.server.serve(handle, host, port, limit) 256 257 # listen on host:port and serve up to `limit` connections with a handler that 258 # does IO; a failed connection is logged and the next one is served 259 def http.serve_io( 260 ~handle: Http.Request -> IO(Http.Reply), 261 host: String, 262 port: U32, 263 limit: U32 264 ) -> IO(Unit): 265 Server.server.serve_io(handle, host, port, limit) 266 267 # listen on host:port and serve with an IO handler until the process stops 268 @unsafe def http.serve_forever( 269 ~handle: Http.Request -> IO(Http.Reply), 270 host: String, 271 port: U32 272 ) -> IO(Unit): 273 Server.server.serve_forever(handle, host, port) 274 275 # listen on host:port, serve one connection, then stop 276 def http.serve_once( 277 ~handle: Http.Request -> Http.Reply, 278 host: String, 279 port: U32 280 ) -> IO(Unit): 281 Server.server.once(handle, host, port)