zokor

Zokor documentation

Hello service

A minimal Slang HTTP service built with Zokor. See how to configure the server, register a route, and return a JSON response.

Markdown · llms.txt · api.json

A zokor service.

Run it from the repository root: make example

examples/hello/main.sl#

examples/hello/main.sl
// A zokor service.
//
// Read the "the service" section below: that is the whole API, and it
// ends at `listen_and_serve`. The section after that is a client, not a
// server: it makes the same requests a curl would, over a real socket,
// so this file demonstrates the service by running it rather than by
// describing it.
import "http";
import "httpc";
import "time";
import "../../zokor" as zokor;

// ---------------------------------------------------------------- //
// the service                                                        //
// ---------------------------------------------------------------- //

gc struct App {
    name: str,
    requests: int,
    token: str,
}

fn home(c: zokor.Ctx[App]) -> http.Response {
    return zokor.text(200, "hello from " + c.state.name);
}

fn search(c: zokor.Ctx[App]) -> http.Response {
    return zokor.text(200, "q=" + c.query("q") +
                           " page=" + to_str(c.query_int("page", 1)));
}

fn show_org(c: zokor.Ctx[App]) -> http.Response {
    let _who = c.local("user_id");     // put there by require_token
    if c.param("id") != "7" {
        return zokor.respond_with(c.errors, "org.not_found",
                                  "no such organisation '" + c.param("id") + "'",
                                  c.request_id);
    }
    return zokor.ok_json("{\"org\":\"7\"}");
}

// A DTO: a declared shape. `zokor.dto` fills it (slang's own
// json.decode underneath), a missing field is an error rather than a
// silent zero, and the field it names is already in the envelope.
gc struct CreateOrg {
    name: str,
    plan: str,
    seats: i32,
    website: opt[str],
}

fn create_org(c: zokor.Ctx[App]) -> http.Response {
    // the wire is camelCase, the struct is snake_case: no tags needed,
    // and a decode failure is already the response to return
    let r: result[CreateOrg, http.Response] = zokor.dto(c);
    guard let dto = r else let resp = err_of(r) {
        return resp;
    }

    // types are checked by the decoder; VALUES are checked here, and
    // every problem is reported at once
    let v = zokor.checker();
    if len(dto.name) == 0 {
        v.note("name", "must not be empty");
    }
    if dto.seats < 1 || dto.seats > 500 {
        v.note("seats", "must be between 1 and 500");
    }
    if dto.plan != "free" && dto.plan != "pro" {
        v.note("plan", "must be one of: free, pro");
    }
    if v.failed() {
        return zokor.respond_fields(c.errors, "validation_failed",
                                    v.fields, c.request_id);
    }

    let body = zokor.jobj()
        .set_str("id", "org_7")
        .set_str("name", dto.name)
        .set_str("plan", dto.plan)
        .set_int("seats", dto.seats as int)
        .set_opt_str("website", dto.website);
    return zokor.created(zokor.camel_keys(body.render()), "/orgs/org_7");
}

// A shape nobody declared: a webhook whose fields belong to somebody
// else. `json_body` walks it without a struct.
fn webhook(c: zokor.Ctx[App]) -> http.Response {
    let r = c.json_body();
    guard let body = r else let e = err_of(r) {
        return zokor.respond_with(c.errors, e.code, e.detail,
                                  c.request_id);
    }
    let event = body.get("type").str_or("unknown");
    let city = body.path("data.customer.address.city").str_or("nowhere");
    let first_item = body.path("data.items.0.sku").str_or("none");
    return zokor.ok_json(zokor.jobj()
        .set_str("event", event)
        .set_str("city", city)
        .set_str("first_item", first_item)
        .set_int("item_count", body.path("data.items").size())
        .render());
}

fn asset(c: zokor.Ctx[App]) -> http.Response {
    return zokor.text(200, "file " + c.param("rest"));
}

// An upload: the rules say what is acceptable, and anything else comes
// back as a registered failure rather than reaching this handler.
fn upload_avatar(c: zokor.Ctx[App]) -> http.Response {
    let rules = zokor.with_max_file_bytes(
        zokor.uploads_allowing(["image/png", "image/jpeg"]), 1048576);
    let r = c.upload(rules);
    guard let form = r else let e = err_of(r) {
        return zokor.respond_with(c.errors, e.code, e.detail,
                                  c.request_id);
    }
    guard let avatar = zokor.file(form, "avatar") else {
        return zokor.respond_with(c.errors, "validation_failed",
                                  "no file was posted under 'avatar'",
                                  c.request_id);
    }
    // avatar.filename is already safe to use as one path segment
    return zokor.ok_json("{\"stored\":\"" + avatar.filename +
                         "\",\"type\":\"" + avatar.content_type +
                         "\",\"bytes\":" + to_str(len(avatar.data)) +
                         ",\"title\":\"" + zokor.value(form, "title") + "\"}");
}

// Middleware. A `before` may end the request, or resolve something and
// hand it to the handler through the context; an `after` sees whatever
// response came back, including one a before produced.
//
// Note what is NOT here: a list of public paths. This hook is attached
// to a group, so it only ever runs for the routes inside it.
fn require_token(c: zokor.Ctx[App]) -> opt[http.Response] {
    if c.bearer() != c.state.token {
        return some(zokor.respond(c.errors, "unauthorized",
                                  c.request_id));
    }
    // resolved once, at the edge, instead of in every handler
    c.set_local("user_id", "u_1");
    return none;
}

fn count(c: zokor.Ctx[App], r: http.Response) -> http.Response {
    c.state.requests = c.state.requests + 1;
    return zokor.with_header(r, "x-request-count", to_str(c.state.requests));
}

// Configuration is read once, here, and validated before anything runs.
let cfg = zokor.load_config();          // base, profile, startup environment

let app = App {
    name: zokor.str_or(cfg, "SERVICE_NAME", "hello"),
    requests: 0,
    token: zokor.str_or(cfg, "API_TOKEN", "s3cret")
};

// new_router's own registry, carried onto every Ctx from here on --
// register the app's own codes on top of the built-ins it already has.
let r = zokor.new_router(app);
zokor.register(r.errors, "org.not_found", 404, "no such organisation");

// public: no authentication
r.get("/", home);
r.get("/search", search);
r.get("/files/*rest", asset);

// everything under /api needs a token, and says so once
let api = r.group("/api");
api.before(require_token);
api.get("/orgs/:id", show_org);
api.post("/orgs", create_org);
api.post("/avatars", upload_avatar);
api.post("/webhooks/stripe", webhook);

// counted for every request, public or not
r.after(count);


// The whole server, from here on. `listen_and_serve` accepts and spawns
// one task per connection; it returns when the process is asked to stop.
// A real service calls it and nothing else -- the spawn here is only so
// this file can also play the client below.
//
// It would also log every request (README, "Logging"). The client below
// prints each exchange itself and the program ends the moment it is done,
// so this demo turns the log off rather than interleave the two; a real
// service keeps it.
let sc = zokor.default_server_config();
sc.logger = zokor.no_logger();
spawn zokor.listen_and_serve_with(r, port(), sc);

// ---------------------------------------------------------------- //
// the demo: a real client, over a real socket                        //
//                                                                    //
// Nothing below is part of zokor's API -- it is here so `make check`  //
// exercises the server end to end rather than describing it.         //
// ---------------------------------------------------------------- //

// A function, not a top-level `let`: the client helpers below are
// functions, and a top-level binding is local to the main task.
fn port() -> int {
    return 18080;
}

fn base() -> str {
    return "http://127.0.0.1:" + to_str(port());
}

fn deadline() -> until {
    return until_of(time.mono() + 5000000000);
}

fn send(rq: httpc.Request) -> httpc.Response {
    let c = httpc.new_client();
    let r2 = httpc.client_send(c, rq, deadline());
    guard let resp = r2 else let e = err_of(r2) {
        println("request failed: " + e);
        exit(1);
    }
    return resp;
}

// Every request carries its own X-Request-Id, which the server reuses
// (it is well-formed): the ids in the envelopes below are this one, so
// the output is the same on every run instead of a fresh random id each.
fn call(method: str, p: str, token: str, body: str, ctype: str) -> httpc.Response {
    let rq = httpc.new_request(method, base() + p);
    rq.headers["x-request-id"] = "example-req-1";
    if len(token) > 0 {
        rq.headers["authorization"] = "Bearer " + token;
    }
    if len(body) > 0 {
        rq.headers["content-type"] = ctype;
        rq.body = to_bytes(body);
    }
    return send(rq);
}

fn get(p: str) -> httpc.Response {
    return call("GET", p, "", "", "");
}

fn json_call(method: str, p: str, token: str, body: str) -> httpc.Response {
    return call(method, p, token, body, "application/json");
}

fn show(label: str, resp: httpc.Response) {
    println(label + " -> " + to_str(resp.status) + " " + to_str(resp.body));
}

// wait for the listener to be up before the first request
fn wait_ready() {
    let i = 0;
    while i < 200 {
        let c = httpc.new_client();
        let r2 = httpc.client_get(c, base() + "/", deadline());
        guard let _ok = r2 else {
            time.sleep(10000000);
            i = i + 1;
            continue;
        }
        return;
    }
    println("server never came up");
    exit(1);
}

wait_ready();

println("routes:");
for line in r.routes_list() {
    println("  " + line);
}
println("");

show("GET    /", get("/"));
show("GET    /search?q=slang&page=2", get("/search?q=slang&page=2"));
show("GET    /api/orgs/7", call("GET", "/api/orgs/7", "s3cret", "", ""));
show("GET    /api/orgs/9", call("GET", "/api/orgs/9", "s3cret", "", ""));
show("GET    /api/orgs/7 (no token)", get("/api/orgs/7"));
show("POST   /api/orgs (empty body)", call("POST", "/api/orgs", "s3cret", "", ""));
show("POST   /api/orgs (bad values)",
     json_call("POST", "/api/orgs", "s3cret",
               "{\"name\":\"\",\"plan\":\"gold\",\"seats\":0}"));
show("POST   /api/orgs (wrong type)",
     json_call("POST", "/api/orgs", "s3cret",
               "{\"name\":\"acme\",\"plan\":\"pro\",\"seats\":\"many\"}"));
show("POST   /api/orgs (missing field)",
     json_call("POST", "/api/orgs", "s3cret", "{\"name\":\"acme\"}"));
show("POST   /api/orgs",
     json_call("POST", "/api/orgs", "s3cret",
               "{\"name\":\"acme\",\"plan\":\"pro\",\"seats\":12,\"website\":\"https://acme.test\"}"));
show("POST   /api/webhooks/stripe",
     json_call("POST", "/api/webhooks/stripe", "s3cret",
               "{\"type\":\"invoice.paid\",\"data\":{\"customer\":{\"address\":{\"city\":\"Lagos\"}},\"items\":[{\"sku\":\"A1\"},{\"sku\":\"B2\"}]}}"));

let avatar = "--B\r\n" +
    "Content-Disposition: form-data; name=\"title\"\r\n\r\n" +
    "My avatar\r\n" +
    "--B\r\n" +
    "Content-Disposition: form-data; name=\"avatar\"; filename=\"../../etc/passwd.png\"\r\n" +
    "Content-Type: image/png\r\n\r\n" +
    "PNGDATA\r\n" +
    "--B--\r\n";
show("POST   /avatars",
     call("POST", "/api/avatars", "s3cret", avatar,
          "multipart/form-data; boundary=B"));

let bad_avatar = "--B\r\n" +
    "Content-Disposition: form-data; name=\"avatar\"; filename=\"x.svg\"\r\n" +
    "Content-Type: image/svg+xml\r\n\r\n" +
    "<svg/>\r\n" +
    "--B--\r\n";
show("POST   /avatars (svg)",
     call("POST", "/api/avatars", "s3cret", bad_avatar,
          "multipart/form-data; boundary=B"));

show("GET    /files/a/b.txt", get("/files/a/b.txt"));
show("HEAD   /", call("HEAD", "/", "", "", ""));
show("DELETE /api/orgs/7", call("DELETE", "/api/orgs/7", "s3cret", "", ""));
show("BREW   /", call("BREW", "/", "", "", ""));
show("GET    /nope", call("GET", "/nope", "s3cret", "", ""));
println("");
// one more than the calls above: wait_ready's probe is a request
// like any other, and the `count` hook counts every one.
println("requests served: " + to_str(app.requests));