plum

#treesitter#compiler#wasm

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