Zokor documentation
Ports and fakes
A runnable Slang example showing how to isolate dependencies behind ports and test Zokor services with fakes.
The example is built from the source files below, with tests beside each package.
Run it from the repository root: make example-ports
examples/ports/src/application/inventory/ports.sl#
examples/ports/src/application/inventory/ports.sl
import "../../domain/inventory" as stock;
// C is explicit adapter state: function fields never capture credentials or globals.
pub gc struct StockPort[C] {
context: C,
lookup: fn(C, str, str) -> result[opt[stock.Stock], str],
}examples/ports/src/application/inventory/service.sl#
examples/ports/src/application/inventory/service.sl
import "../../domain/inventory" as stock;
import "strings";
pub gc struct Service[C] { port: StockPort[C] }
pub fn new_service[C](port: StockPort[C]) -> Service[C] { return Service[C] { port: port }; }
// Tenant identity is resolved at the edge and passed through every port call.
pub fn find[C](service: Service[C], tenant_id: str, sku: str) -> result[opt[stock.Stock], str] {
let checked = stock.key(tenant_id, sku);
guard let _key = checked else let e = err_of(checked) { return err(e); }
let lookup = service.port.lookup;
let loaded = lookup(service.port.context, tenant_id, sku);
guard let maybe = loaded else let e = err_of(loaded) { return err(e); }
if let row = maybe {
if row.tenant_id != tenant_id || row.sku != sku { return err("adapter returned a different tenant_id or sku"); }
if row.available < 0 { return err("adapter returned a negative available count"); }
}
return ok(maybe);
}
// Share the domain rule with the HTTP edge without importing adapters.
pub fn valid_sku(sku: str) -> bool { return stock.identifier(sku); }examples/ports/src/domain/inventory/stock.sl#
examples/ports/src/domain/inventory/stock.sl
import "strings";
pub struct Stock { tenant_id: str, sku: str, available: int }
pub fn identifier(value: str) -> bool {
if len(value) < 1 || len(value) > 64 { return false; }
for ch in to_bytes(value) {
if !((ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) ||
(ch >= 48 && ch <= 57) || ch == 45 || ch == 95) { return false; }
}
return true;
}
// The separator cannot occur in either identifier: tenant keys stay distinct.
pub fn key(tenant_id: str, sku: str) -> result[str, str] {
let problems: [str] = [];
if !identifier(tenant_id) { push(problems, "tenant_id must contain 1..64 ASCII letters, digits, - or _"); }
if !identifier(sku) { push(problems, "sku must contain 1..64 ASCII letters, digits, - or _"); }
if len(problems) > 0 { return err(strings.join(problems, "; ")); }
return ok(tenant_id + ":" + sku);
}
pub fn new_stock(tenant_id: str, sku: str, available: int) -> result[Stock, str] {
let problems: [str] = [];
let checked = key(tenant_id, sku);
if let _key = checked {} else let e = err_of(checked) { push(problems, e); }
if available < 0 { push(problems, "available must be nonnegative"); }
if len(problems) > 0 { return err(strings.join(problems, "; ")); }
return ok(Stock { tenant_id: tenant_id, sku: sku, available: available });
}examples/ports/src/infrastructure/memory/client.sl#
examples/ports/src/infrastructure/memory/client.sl
import "../../application/inventory" as inventory;
import "../../domain/inventory" as stock;
import "strings";
// Seed once at startup; immutable afterwards, so readers do not need a lock.
pub gc struct Store { stocks: map[str]stock.Stock }
pub fn new_store(rows: [stock.Stock]) -> result[Store, str] {
let values: map[str]stock.Stock = {};
let problems: [str] = [];
let i = 0;
for row in rows {
let made = stock.new_stock(row.tenant_id, row.sku, row.available);
guard let checked = made else let e = err_of(made) {
push(problems, "rows[" + to_str(i) + "]: " + e); i += 1; continue;
}
let key = stock.key(checked.tenant_id, checked.sku) ?? "";
if has(values, key) { push(problems, "rows[" + to_str(i) + "]: duplicate tenant_id and sku"); }
else { values[key] = checked; }
i += 1;
}
if len(problems) > 0 { return err(strings.join(problems, "; ")); }
return ok(Store { stocks: values });
}
pub fn lookup(ctx: Store, tenant_id: str, sku: str) -> result[opt[stock.Stock], str] {
let checked = stock.key(tenant_id, sku);
guard let key = checked else let e = err_of(checked) { return err(e); }
if !has(ctx.stocks, key) { let absent: opt[stock.Stock] = none; return ok(absent); }
return ok(some(ctx.stocks[key]));
}
pub fn new_stock_port(ctx: Store) -> inventory.StockPort[Store] {
return inventory.StockPort[Store] { context: ctx, lookup: lookup };
}examples/ports/src/interfaces/web/router.sl#
examples/ports/src/interfaces/web/router.sl
import "../../../../../zokor" as zokor;
import "../../application/inventory" as inventory;
import "http";
import "json";
pub fn register_errors(errors: zokor.Registry) {
zokor.register(errors, "inventory.not_found", 404, "stock not found");
zokor.register(errors, "inventory.unavailable", 503, "inventory is unavailable");
}
// The route wrapper resolves authentication and passes explicit values here.
pub fn show[C](service: inventory.Service[C], tenant_id: str, sku: str,
errors: zokor.Registry, request_id: str) -> http.Response {
if !inventory.valid_sku(sku) {
return zokor.respond_fields(errors, "validation_failed",
[zokor.field("sku", "must contain 1..64 ASCII letters, digits, - or _")], request_id);
}
let loaded = inventory.find(service, tenant_id, sku);
guard let maybe = loaded else { return zokor.respond(errors, "inventory.unavailable", request_id); }
guard let row = maybe else { return zokor.respond(errors, "inventory.not_found", request_id); }
return zokor.ok_json(json.encode(row));
}examples/ports/src/main.sl#
examples/ports/src/main.sl
// Startup wiring is the only package that knows the concrete adapter.
import "application/inventory" as inventory;
import "domain/inventory" as stock;
import "infrastructure/memory" as memory;
import "interfaces/web" as rest;
import "../../../zokor" as zokor;
import "http";
gc struct App { inventory: inventory.Service[memory.Store], tokens: zokor.TokenStore }
fn require_tenant(c: zokor.Ctx[App]) -> opt[http.Response] {
guard let tenant_id = zokor.bearer_subject(c, c.state.tokens) else {
return some(zokor.respond(c.errors, "unauthorized", c.request_id));
}
c.set_local("tenant_id", tenant_id);
return none;
}
fn show_stock(c: zokor.Ctx[App]) -> http.Response {
return rest.show(c.state.inventory, c.local("tenant_id"), c.param("sku"), c.errors, c.request_id);
}
fn main() {
let cfg = zokor.load_config();
let count = zokor.int_or(cfg, "EXAMPLE_STOCK", 7);
let checked = zokor.check(cfg);
guard let _config = checked else let e = err_of(checked) { println(e); exit(1); }
let made = stock.new_stock("tenant_a", "pen", count);
guard let row = made else let e = err_of(made) { println(e); exit(1); }
let seeded = memory.new_store([row]);
guard let adapter = seeded else let e = err_of(seeded) { println(e); exit(1); }
let service = inventory.new_service(memory.new_stock_port(adapter));
let tokens = zokor.new_token_store(0);
let issued = tokens.issue("tenant_a");
guard let token = issued else { println("token issue failed"); exit(1); }
let router = zokor.new_router(App { inventory: service, tokens: tokens });
rest.register_errors(router.errors);
router.before(require_tenant);
router.get("/stock/:sku", show_stock);
// Drive the same handler pipeline hermetically. A server passes router to listen_and_serve.
let denied = router.serve(zokor.get_request("/stock/pen").build());
println("unauthenticated -> " + to_str(denied.status));
let found = router.serve(zokor.get_request("/stock/pen").bearer(token).build());
println("stock -> " + to_str(found.status) + " " + zokor.resp_text(found));
let missing = router.serve(zokor.get_request("/stock/missing").bearer(token).build());
println("missing -> " + to_str(missing.status) + " " + zokor.resp_error_code(missing));
}
main();