Networking
SSPUR has an HTTP client and an HTTP/1.1 server built in. Both perform the net effect, run the same in native code and in the interpreter, and use TLS: the client verifies certificates by default, and the server can terminate TLS itself, though production deployments usually leave that to a proxy or load balancer in front of it.
A server
A server is a svc: a list of routes, each naming an ordinary function. Path segments like {id} bind the parameter of that name, other parameters of a GET or DELETE come from the query string (Opt ones may be missing), and the one other parameter of a POST, PUT or PATCH is the JSON body. A parameter of type Request gets the raw request.
type Todo = {id: Int, title: Str, done: Bool}
type TodoErr = NotFound{id: Int}
fn todo(id: Int) -> Todo ! fail[TodoErr]
= if id == 1 then Todo{id, title: "write the docs", done: false} else raise NotFound{id}
fn todos(done: Opt[Bool]) -> List[Todo]
= [Todo{id: 1, title: "write the docs", done: false}].filter(t => done.is_none or done == some(t.done))
fn add(t: Todo) -> Todo
= t with done := false
fn hello(req: Request, name: Str) -> Response
= http.text(200, "hello {name}, you asked for {req.path}")
svc api
ep get "/todos/{id}" = todo
ep get "/todos" = todos
ep post "/todos" = add
ep get "/hello/{name}" = hello
http.serve(port, api) serves it. http.serve_with takes a Server value instead of a port, which http.server(port) makes with defaults that you change with with:
fn main() -> Unit ! net, log, env
= do
port = env_var("PORT").or("8080").to_int.or(8080)
http.serve_with(http.server(port) with gzip := true, health := "/healthz", api)
Results become responses the same way as in a deployed service (ADR 0016): a value is 200 with its JSON (201 for a POST), Unit is 204, an Opt that is none is 404, and a raised error is {"error": value} with a status taken from the variant’s name (NotFound is 404, Conflict 409, Invalid 400, Unauthorized 401, Forbidden 403, TooMany 429, others 422). A handler that returns a Response sends exactly that. Parameters that don’t parse are 400 with a message, and a handler that traps is 500 and a line in the log:
$ sspur run server.ssp &
info: listening on http://127.0.0.1:8080
$ curl -s -w ' %{http_code}\n' localhost:8080/todos/1
{"id":1,"title":"write the docs","done":false} 200
$ curl -s -w ' %{http_code}\n' localhost:8080/todos/9
{"error":{"tag":"NotFound","id":9}} 404
$ curl -s -w ' %{http_code}\n' localhost:8080/todos/x
{"error":"invalid request","detail":"path parameter id: expected Int"} 400
$ curl -s -w ' %{http_code}\n' -X POST -d '{"id": 2, "title": "ship it", "done": true}' localhost:8080/todos
{"id":2,"title":"ship it","done":false} 201
$ curl -s -w ' %{http_code}\n' localhost:8080/hello/ada
hello ada, you asked for /hello/ada 200
The server answers each request on a pool of threads (workers, by default one per core up to 8), keeps connections alive, and gives every request an id: the client’s X-Request-Id if it sent one, otherwise a fresh one, in the request’s headers, in the response and in the access log. SIGINT or SIGTERM (Ctrl+C) stops it gracefully: it stops accepting, finishes the requests in flight, then http.serve returns.
The Server fields cover what usually takes middleware:
| Field | Default | What it does |
|---|---|---|
port, host | "127.0.0.1" | Where to listen; 0 picks a free port. Use "0.0.0.0" to accept connections from other machines |
workers | 0 (one per core, up to 8) | Threads that run handlers |
max_body | 1048576 | Larger request bodies get 413 |
timeout_ms | 30000 | Time to receive a request, and for the handler to answer before the client gets 503 |
max_requests | 0 (no limit) | Stop after this many requests, which is handy in tests |
cors | [] | Origins allowed to call from a browser, or ["*"]; preflight requests are answered for you |
gzip | false | Compress text responses of 1 KB or more for clients that accept gzip |
files | [] | (prefix, dir) pairs served as static files, such as [("/assets", "public")] |
health | "" | A path that answers 200 ok, and 503 while shutting down |
cert, key | "" | PEM files to serve HTTPS (needs OpenSSL 3 on the machine) |
access_log | true | One log line per request with method, path, status, time and id |
A svc can take parameters for what its handlers share, such as the database and the settings: with svc api(app: App), http.serve(8080, api(app)) serves it, and every endpoint parameter named app of type App gets that value, the same way {id} gets the path segment. api(app) is an ordinary function from Request to Response, so a test can call it with a request it builds. Build a web app uses this for its whole API.
A handler is also just a function from Request to Response, so anything else a middleware would do is a function in front of the svc: http.serve(8080, req => if authorized(req) then api(req) else http.text(401, "no")). req.path, req.query(name), req.header(name) and req.cookie(name) read the request, req.json[T] decodes its body and req.form(name) reads a field of an HTML form, and http.text, http.html and http.json(status, value) build responses. Handlers run concurrently, so like par tasks they can’t assign variables from outside (E_PAR_RACE); share counters through an Atomic[Int].
A client
http.get(url), http.post(url, json_body) and http.send(request) return a Res[Response, HttpErr]. Only failures to get a response are errors; a 404 is a response with status 404. http.req(method, url) makes a Request with defaults (30 seconds, no retries, up to 10 redirects) that with changes:
fn main() -> Unit ! net, log, env
= do
base = "http://127.0.0.1:{args().first.or("8080")}"
match http.get(base + "/todos/1")
| ok(r) => log("{r.status} {r.header("content-type").or("")} {r.json[Todo]}")
| err(e) => log("failed: {e}")
r = http.send(http.req("GET", base + "/todos?done=false") with timeout_ms := 5000, retries := 2)
log(r.map(x => x.body).or("failed"))
log(catch title(base, 2)
| HttpStatus{code, body} => "HTTP {code}: {body}"
| e => "failed: {e}")
http.fetch(url) is the short form for when anything but a 2xx should stop the function: it returns the body and raises HttpErr otherwise.
fn title(base: Str, id: Int) -> Str ! net, fail[HttpErr]
= do
body = http.fetch("{base}/todos/{id}")
json.decode[Todo](body).map(t => t.title).or("?")
$ sspur run client.ssp 8080
200 application/json ok(Todo{id: 1, title: "write the docs", done: false})
[{"id":1,"title":"write the docs","done":false}]
HTTP 404: {"error":{"tag":"NotFound","id":2}}
HttpErr is Dns{msg}, Connect{msg}, Tls{msg}, Timeout{msg}, HttpStatus{code, body} (from http.fetch) or Io{msg}. The client follows redirects, decodes gzip, uses the proxy in HTTPS_PROXY, HTTP_PROXY and NO_PROXY, and with retries it retries connection failures, timeouts, 429, 500, 502, 503 and 504 with exponential backoff (honoring Retry-After). SSL_CERT_FILE names a PEM file of certificates to trust instead of the system’s. http.download(request, path) writes the body to a file (also ! fs), which is how to get binary content, since a body in memory is a Str and must be UTF-8.
Configuration and logs
config.load(defaults, path, prefix) reads a record from a JSON file and the environment: each field comes from the variable prefix plus its name in capitals if that is set, else from the file, else from the defaults, so the file is optional. where clauses on the fields check the result, and pre and post on the function that loads it work as usual:
type Config = {port: Int where _ > 0 and _ < 65536, greeting: Str, workers: Int}
fn load() -> Res[Config, Str] ! fs, env
= config.load(Config{port: 8080, greeting: "hello", workers: 4}, "app.json", "APP_")
fn main() -> Unit ! fs, env, log
= match load()
| ok(c) => do
log.info("port {c.port}, {c.workers} workers, greeting {c.greeting}")
log.warn("this is a warning")
| err(e) => log.error(e)
{"greeting": "hi there"}
log.debug, log.info, log.warn and log.error write a line with that level in front (warn: ...), which a handle of log sees like any other line. SSPUR_LOG_LEVEL=warn drops the lower levels, and SSPUR_LOG=json prints each line as a JSON object with the time, the level and the message, for log collectors. A message that is a JSON object, log.info(json.encode(event)), puts its fields in the line instead, and the access log then has method, path, status, ms and the request id as fields:
$ sspur run config.ssp
info: port 8080, 4 workers, greeting hi there
warn: this is a warning
$ APP_WORKERS=x sspur run config.ssp
error: APP_WORKERS: expected Int, found x
$ APP_PORT=0 sspur run config.ssp
error: port: must satisfy _ > 0 and _ < 65536
$ SSPUR_LOG_LEVEL=warn sspur run config.ssp
warn: this is a warning
In the playground
The playground runs in a browser, which can’t open sockets: the client returns Io{msg: "networking is not available in the playground"} and http.serve stops with serving HTTP is not available in the playground. Everything else, including Request and Response values, works.
Next: Packages.