plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-std/Http.plum
| fa31ba4 | 1 | module std |
| e502e58 | 2 | |
| b7071c9 | 3 | import std/Buffer |
| b7071c9 | 4 | import std/Json |
| b7071c9 | 5 | import std/List |
| b7071c9 | 6 | import std/Map |
| b7071c9 | 7 | import std/Option |
| b7071c9 | 8 | import std/Result |
| b7071c9 | 9 | import std/Str |
| 73b5e55 | 10 | import std/Bool |
| ca5fd6f | 11 | import std/Number |
| 19b8452 | 12 | |
| bf629a2 | 13 | # An HTTP response: status code, response headers, and the raw response |
| bf629a2 | 14 | # body as a `Str` (a byte array, so binary bodies round-trip intact). |
| 5f2f962 | 15 | enum Response = |
| 5f2f962 | 16 | | Response(status: Int, headers: Map[Str, Str], body: Str) |
| 6a5b4e2 | 17 | |
| bf629a2 | 18 | # 2xx status codes are success; everything else (redirects, 4xx, 5xx) is |
| bf629a2 | 19 | # surfaced here rather than as an `Err` from `request` — a non-2xx |
| bf629a2 | 20 | # response is still a complete, well-formed HTTP exchange, not a failed |
| bf629a2 | 21 | # one (`request` itself only fails for transport-level problems like a |
| bf629a2 | 22 | # DNS/connection error). |
| bf629a2 | 23 | fun ok(self) -> Bool = |
| bf629a2 | 24 | self.status >= 200 && self.status < 300 |
| bf629a2 | 25 | |
| bf629a2 | 26 | # Parses `body` as JSON. Fails the same way `Json.fromStr` does for a |
| bf629a2 | 27 | # response body that isn't valid JSON. |
| bf629a2 | 28 | fun json(self) -> Result[Json, JsonParseError] = |
| bf629a2 | 29 | return Json.fromStr(self.body) |
| bf629a2 | 30 | |
| bf629a2 | 31 | fun emptyHeaders() -> Map[Str, Str] = |
| 6e5d12f | 32 | return Map[Str, Str]() |
| bf629a2 | 33 | |
| bf629a2 | 34 | # A single blocking network round trip — this VM has no async/event-loop |
| bf629a2 | 35 | # mechanism, so `rawHttpRequest` runs to completion before returning (same |
| bf629a2 | 36 | # idea as `libs/std/os.plum`'s `rawReadFile`). `headers` is `"Key: Value"` |
| bf629a2 | 37 | # lines joined by `\n` (`""` for none). The result packs an outcome flag, |
| bf629a2 | 38 | # then either the status code and response headers (success) or an error |
| bf629a2 | 39 | # message (failure), as fields joined by `\x01`, up to the first `\x00` — |
| bf629a2 | 40 | # everything after that byte is the raw response body. `\x00`/`\x01` can |
| bf629a2 | 41 | # never occur in the metadata fields themselves (the flag/status are |
| bf629a2 | 42 | # decimal digits, header names/values and the error message are always |
| bf629a2 | 43 | # plain text), so they safely mark where the opaque body bytes start even |
| bf629a2 | 44 | # when the body itself is arbitrary binary data. |
| bf629a2 | 45 | extern fun rawHttpRequest(method: Str, url: Str, headers: Str, body: Str) -> Str |
| bf629a2 | 46 | |
| bf629a2 | 47 | # Packs `headers` into the `"Key: Value"`-per-line format `rawHttpRequest` |
| bf629a2 | 48 | # expects for its own `headers` argument. |
| bf629a2 | 49 | fun encodeHeaders(headers: Map[Str, Str]) -> Str = |
| bf629a2 | 50 | buf := Buffer() |
| bf629a2 | 51 | headers.each(|k, v| |
| bf629a2 | 52 | if buf.length() > 0 |
| bf629a2 | 53 | buf.write("\n") |
| bf629a2 | 54 | buf.write(k) |
| bf629a2 | 55 | buf.write(": ") |
| bf629a2 | 56 | buf.write(v)) |
| bf629a2 | 57 | return buf.toStr() |
| bf629a2 | 58 | |
| bf629a2 | 59 | # The reverse of `encodeHeaders`, for the response headers `rawHttpRequest` |
| bf629a2 | 60 | # packs into its result. |
| bf629a2 | 61 | fun decodeHeaders(raw: Str) -> Map[Str, Str] = |
| bf629a2 | 62 | result := emptyHeaders() |
| bf629a2 | 63 | if raw == "" |
| bf629a2 | 64 | return result |
| bf629a2 | 65 | raw.split("\n", 0).each(|line| |
| bf629a2 | 66 | idx := line.indexOf(": ") |
| bf629a2 | 67 | if idx >= 0 |
| bf629a2 | 68 | key := line.sub(0, idx) |
| bf629a2 | 69 | val := line.sub(idx + 2, line.length()) |
| bf629a2 | 70 | result.set(key, val)) |
| bf629a2 | 71 | return result |
| bf629a2 | 72 | |
| bf629a2 | 73 | # Performs a single HTTP request and waits for the full response. Fails |
| bf629a2 | 74 | # only for a transport-level problem (DNS failure, connection refused, TLS |
| bf629a2 | 75 | # error, ...) — an HTTP-level error status (404, 500, ...) is still a |
| bf629a2 | 76 | # successful round trip, returned as `Ok(Response(...))` with that status |
| bf629a2 | 77 | # (check `Response.ok()`). |
| bf629a2 | 78 | fun request(method: Str, url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] = |
| bf629a2 | 79 | raw := rawHttpRequest(method, url, encodeHeaders(headers), body) |
| bf629a2 | 80 | sep := raw.indexOf("\x00") |
| bf629a2 | 81 | meta := sep >= 0 ? raw.sub(0, sep) : raw |
| bf629a2 | 82 | resp_body := sep >= 0 ? raw.sub(sep + 1, raw.length()) : "" |
| bf629a2 | 83 | parts := meta.split("\x01", 3) |
| bf629a2 | 84 | flag := parts.get(0).unwrapOr("") |
| bf629a2 | 85 | if flag != "1" |
| bf629a2 | 86 | return Err(parts.get(2).unwrapOr("http request failed")) |
| ca5fd6f | 87 | match parseInt(parts.get(1).unwrapOr("")) |
| bf629a2 | 88 | Ok(status) => |
| bf629a2 | 89 | return Ok(Response(status: status, headers: decodeHeaders(parts.get(2).unwrapOr("")), body: resp_body)) |
| bf629a2 | 90 | Err(e) => |
| bf629a2 | 91 | return Err("invalid status code in http response: {e}") |
| bf629a2 | 92 | |
| bf629a2 | 93 | fun get(url: Str, headers: Map[Str, Str]) -> Result[Response, Str] = |
| bf629a2 | 94 | return request("GET", url, headers, "") |
| bf629a2 | 95 | |
| bf629a2 | 96 | fun head(url: Str, headers: Map[Str, Str]) -> Result[Response, Str] = |
| bf629a2 | 97 | return request("HEAD", url, headers, "") |
| bf629a2 | 98 | |
| bf629a2 | 99 | fun delete(url: Str, headers: Map[Str, Str]) -> Result[Response, Str] = |
| bf629a2 | 100 | return request("DELETE", url, headers, "") |
| bf629a2 | 101 | |
| bf629a2 | 102 | fun post(url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] = |
| bf629a2 | 103 | return request("POST", url, headers, body) |
| bf629a2 | 104 | |
| bf629a2 | 105 | fun put(url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] = |
| bf629a2 | 106 | return request("PUT", url, headers, body) |
| bf629a2 | 107 | |
| bf629a2 | 108 | fun patch(url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] = |
| bf629a2 | 109 | return request("PATCH", url, headers, body) |
| bf629a2 | 110 | |
| bf629a2 | 111 | # Convenience wrapper: sends `body` as a JSON `Str`, adding a |
| bf629a2 | 112 | # `Content-Type: application/json` header, and parses the response back as |
| bf629a2 | 113 | # JSON via `Response.json()`. |
| bf629a2 | 114 | fun postJson(url: Str, headers: Map[Str, Str], body: Json) -> Result[Response, Str] = |
| bf629a2 | 115 | headers.set("Content-Type", "application/json") |
| bf629a2 | 116 | return post(url, headers, body.toStr()) |
| bf629a2 | 117 | |
| bf629a2 | 118 | test "encodeHeaders/decodeHeaders round-trip a header map" |
| bf629a2 | 119 | h := emptyHeaders() |
| bf629a2 | 120 | h.set("Content-Type", "application/json") |
| bf629a2 | 121 | h.set("Accept", "*/*") |
| bf629a2 | 122 | decoded := decodeHeaders(encodeHeaders(h)) |
| a9a0147 | 123 | assert decoded.get("Content-Type").unwrap() == "application/json" |
| a9a0147 | 124 | assert decoded.get("Accept").unwrap() == "*/*" |
| bf629a2 | 125 | |
| bf629a2 | 126 | test "decodeHeaders returns an empty map for an empty string" |
| a9a0147 | 127 | assert decodeHeaders("").isEmpty() == True |
| bf629a2 | 128 | |
| bf629a2 | 129 | test "get performs a real HTTP round trip" |
| bf629a2 | 130 | match get("https://httpbin.org/get", emptyHeaders()) |
| bf629a2 | 131 | Ok(resp) => |
| a9a0147 | 132 | assert resp.ok() == True |
| a9a0147 | 133 | assert resp.status == 200 |
| bf629a2 | 134 | Err(_) => |
| a9a0147 | 135 | assert True == False |
| bf629a2 | 136 | |
| bf629a2 | 137 | test "get reports a non-2xx status without failing the request" |
| bf629a2 | 138 | match get("https://httpbin.org/status/404", emptyHeaders()) |
| bf629a2 | 139 | Ok(resp) => |
| a9a0147 | 140 | assert resp.ok() == False |
| a9a0147 | 141 | assert resp.status == 404 |
| bf629a2 | 142 | Err(_) => |
| a9a0147 | 143 | assert True == False |
| bf629a2 | 144 | |
| bf629a2 | 145 | test "post sends a body and the server echoes it back" |
| bf629a2 | 146 | match post("https://httpbin.org/post", emptyHeaders(), "hello world") |
| bf629a2 | 147 | Ok(resp) => |
| a9a0147 | 148 | assert resp.ok() == True |
| bf629a2 | 149 | match resp.json() |
| bf629a2 | 150 | Ok(j) => |
| a9a0147 | 151 | assert j.asMap().unwrap().get("data").unwrap().asStr().unwrap() == "hello world" |
| bf629a2 | 152 | Err(_) => |
| a9a0147 | 153 | assert True == False |
| bf629a2 | 154 | Err(_) => |
| a9a0147 | 155 | assert True == False |
| bf629a2 | 156 | |
| bf629a2 | 157 | test "request fails for an unreachable host" |
| bf629a2 | 158 | match get("http://this-host-does-not-exist.invalid/", emptyHeaders()) |
| bf629a2 | 159 | Ok(_) => |
| a9a0147 | 160 | assert True == False |
| bf629a2 | 161 | Err(_) => |
| a9a0147 | 162 | assert True == True |