plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-std/Http.plum
module std
import std/Buffer
import std/Json
import std/List
import std/Map
import std/Option
import std/Result
import std/Str
import std/Bool
import std/Number
# An HTTP response: status code, response headers, and the raw response
# body as a `Str` (a byte array, so binary bodies round-trip intact).
enum Response =
| Response(status: Int, headers: Map[Str, Str], body: Str)
# 2xx status codes are success; everything else (redirects, 4xx, 5xx) is
# surfaced here rather than as an `Err` from `request` — a non-2xx
# response is still a complete, well-formed HTTP exchange, not a failed
# one (`request` itself only fails for transport-level problems like a
# DNS/connection error).
fun ok(self) -> Bool =
self.status >= 200 && self.status < 300
# Parses `body` as JSON. Fails the same way `Json.fromStr` does for a
# response body that isn't valid JSON.
fun json(self) -> Result[Json, JsonParseError] =
return Json.fromStr(self.body)
fun emptyHeaders() -> Map[Str, Str] =
return Map[Str, Str]()
# A single blocking network round trip — this VM has no async/event-loop
# mechanism, so `rawHttpRequest` runs to completion before returning (same
# idea as `libs/std/os.plum`'s `rawReadFile`). `headers` is `"Key: Value"`
# lines joined by `\n` (`""` for none). The result packs an outcome flag,
# then either the status code and response headers (success) or an error
# message (failure), as fields joined by `\x01`, up to the first `\x00` —
# everything after that byte is the raw response body. `\x00`/`\x01` can
# never occur in the metadata fields themselves (the flag/status are
# decimal digits, header names/values and the error message are always
# plain text), so they safely mark where the opaque body bytes start even
# when the body itself is arbitrary binary data.
extern fun rawHttpRequest(method: Str, url: Str, headers: Str, body: Str) -> Str
# Packs `headers` into the `"Key: Value"`-per-line format `rawHttpRequest`
# expects for its own `headers` argument.
fun encodeHeaders(headers: Map[Str, Str]) -> Str =
buf := Buffer()
headers.each(|k, v|
if buf.length() > 0
buf.write("\n")
buf.write(k)
buf.write(": ")
buf.write(v))
return buf.toStr()
# The reverse of `encodeHeaders`, for the response headers `rawHttpRequest`
# packs into its result.
fun decodeHeaders(raw: Str) -> Map[Str, Str] =
result := emptyHeaders()
if raw == ""
return result
raw.split("\n", 0).each(|line|
idx := line.indexOf(": ")
if idx >= 0
key := line.sub(0, idx)
val := line.sub(idx + 2, line.length())
result.set(key, val))
return result
# Performs a single HTTP request and waits for the full response. Fails
# only for a transport-level problem (DNS failure, connection refused, TLS
# error, ...) — an HTTP-level error status (404, 500, ...) is still a
# successful round trip, returned as `Ok(Response(...))` with that status
# (check `Response.ok()`).
fun request(method: Str, url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] =
raw := rawHttpRequest(method, url, encodeHeaders(headers), body)
sep := raw.indexOf("\x00")
meta := sep >= 0 ? raw.sub(0, sep) : raw
resp_body := sep >= 0 ? raw.sub(sep + 1, raw.length()) : ""
parts := meta.split("\x01", 3)
flag := parts.get(0).unwrapOr("")
if flag != "1"
return Err(parts.get(2).unwrapOr("http request failed"))
match parseInt(parts.get(1).unwrapOr(""))
Ok(status) =>
return Ok(Response(status: status, headers: decodeHeaders(parts.get(2).unwrapOr("")), body: resp_body))
Err(e) =>
return Err("invalid status code in http response: {e}")
fun get(url: Str, headers: Map[Str, Str]) -> Result[Response, Str] =
return request("GET", url, headers, "")
fun head(url: Str, headers: Map[Str, Str]) -> Result[Response, Str] =
return request("HEAD", url, headers, "")
fun delete(url: Str, headers: Map[Str, Str]) -> Result[Response, Str] =
return request("DELETE", url, headers, "")
fun post(url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] =
return request("POST", url, headers, body)
fun put(url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] =
return request("PUT", url, headers, body)
fun patch(url: Str, headers: Map[Str, Str], body: Str) -> Result[Response, Str] =
return request("PATCH", url, headers, body)
# Convenience wrapper: sends `body` as a JSON `Str`, adding a
# `Content-Type: application/json` header, and parses the response back as
# JSON via `Response.json()`.
fun postJson(url: Str, headers: Map[Str, Str], body: Json) -> Result[Response, Str] =
headers.set("Content-Type", "application/json")
return post(url, headers, body.toStr())
test "encodeHeaders/decodeHeaders round-trip a header map"
h := emptyHeaders()
h.set("Content-Type", "application/json")
h.set("Accept", "*/*")
decoded := decodeHeaders(encodeHeaders(h))
assert decoded.get("Content-Type").unwrap() == "application/json"
assert decoded.get("Accept").unwrap() == "*/*"
test "decodeHeaders returns an empty map for an empty string"
assert decodeHeaders("").isEmpty() == True
test "get performs a real HTTP round trip"
match get("https://httpbin.org/get", emptyHeaders())
Ok(resp) =>
assert resp.ok() == True
assert resp.status == 200
Err(_) =>
assert True == False
test "get reports a non-2xx status without failing the request"
match get("https://httpbin.org/status/404", emptyHeaders())
Ok(resp) =>
assert resp.ok() == False
assert resp.status == 404
Err(_) =>
assert True == False
test "post sends a body and the server echoes it back"
match post("https://httpbin.org/post", emptyHeaders(), "hello world")
Ok(resp) =>
assert resp.ok() == True
match resp.json()
Ok(j) =>
assert j.asMap().unwrap().get("data").unwrap().asStr().unwrap() == "hello world"
Err(_) =>
assert True == False
Err(_) =>
assert True == False
test "request fails for an unreachable host"
match get("http://this-host-does-not-exist.invalid/", emptyHeaders())
Ok(_) =>
assert True == False
Err(_) =>
assert True == True