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
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