use super::api::API_VERSION;
pub struct Route {
pub method: &'static str,
pub path: &'static str,
pub summary: &'static str,
pub who: &'static str,
pub deploy: bool,
}
pub const ROUTES: &[Route] = &[
Route {
method: "get",
path: "/healthz",
summary: "Whether this host should be sent work.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/version",
summary: "Zygo's version, the HTTP surface's, and the control protocol's.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/metrics",
summary: "Prometheus text exposition.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/fn",
summary: "Warm functions. A tenant token sees its own.",
who: "any",
deploy: false,
},
Route {
method: "post",
path: "/fn/{name}",
summary: "Call a warm function. `?stream=1` for NDJSON, `?out=1` for the workspace back.",
who: "any",
deploy: false,
},
Route {
method: "put",
path: "/fn/{name}",
summary: "Warm a function, replacing whatever held the name.",
who: "operator",
deploy: true,
},
Route {
method: "delete",
path: "/fn/{name}",
summary: "Stop a function.",
who: "operator",
deploy: true,
},
Route {
method: "post",
path: "/fn/{name}/batch",
summary: "Several events at once; each element carries its own status.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/fn/{name}/stats",
summary: "One function's counters.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/fn/{name}/logs",
summary: "A function's recent log.",
who: "any",
deploy: false,
},
Route {
method: "post",
path: "/fn/{name}/warm",
summary: "Bring a registered function up without calling it.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/runtimes",
summary: "Runtime pools. A tenant token sees its own.",
who: "any",
deploy: false,
},
Route {
method: "post",
path: "/runtimes",
summary: "Register a runtime pool: an image and a dependency set, no code.",
who: "operator",
deploy: true,
},
Route {
method: "delete",
path: "/runtimes/{name}",
summary: "Stop a pool and drop its zygotes.",
who: "operator",
deploy: true,
},
Route {
method: "post",
path: "/runtimes/{name}/call",
summary: "Run one script in a pool. `?stream=1`, `?out=1`.",
who: "any",
deploy: false,
},
Route {
method: "post",
path: "/deps",
summary: "Build a dependency set from a lockfile. Answers `building`; poll it.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/deps",
summary: "Every dependency set you can see.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/deps/{id}",
summary: "One dependency set: building, ready or failed, with its build log.",
who: "any",
deploy: false,
},
Route {
method: "delete",
path: "/deps/{id}",
summary: "Forget a dependency set. Refused while a pool is built on it.",
who: "operator",
deploy: true,
},
Route {
method: "put",
path: "/scripts",
summary: "Register a script; the body is the script. Idempotent by digest.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/scripts/{digest}",
summary: "Whether this host holds a script, and how big it is.",
who: "any",
deploy: false,
},
Route {
method: "delete",
path: "/scripts/{digest}",
summary: "Forget a script. The store is shared by digest.",
who: "operator",
deploy: true,
},
Route {
method: "put",
path: "/blobs",
summary: "Store a tar for workspaces; the body is the tar. Idempotent by digest.",
who: "any",
deploy: false,
},
Route {
method: "get",
path: "/blobs/{digest}",
summary: "Whether this host holds a blob, and how big it is.",
who: "any",
deploy: false,
},
Route {
method: "delete",
path: "/blobs/{digest}",
summary: "Forget a blob. The store is shared by digest.",
who: "operator",
deploy: true,
},
Route {
method: "post",
path: "/run",
summary: "One-shot sandbox. Names an image and a command, so it is a shell.",
who: "operator",
deploy: true,
},
Route {
method: "delete",
path: "/requests/{id}",
summary: "Stop a running request, by its id or the key it was called with.",
who: "any",
deploy: false,
},
Route {
method: "post",
path: "/drain",
summary: "Stop admitting, finish what is running, then exit.",
who: "operator",
deploy: true,
},
Route {
method: "get",
path: "/tenants",
summary: "Every tenant this host holds.",
who: "operator",
deploy: false,
},
Route {
method: "post",
path: "/tenants",
summary: "Register a tenant, or find the one already registered.",
who: "operator",
deploy: false,
},
Route {
method: "get",
path: "/tenants/{id}",
summary: "One tenant. A tenant may read its own.",
who: "tenant-or-operator",
deploy: false,
},
Route {
method: "delete",
path: "/tenants/{id}",
summary: "Forget a tenant: its work stops and its scripts, tokens and secrets go.",
who: "operator",
deploy: true,
},
Route {
method: "patch",
path: "/tenants/{id}/limits",
summary: "What a tenant may not exceed. These only narrow.",
who: "operator",
deploy: true,
},
Route {
method: "get",
path: "/tenants/{id}/secrets",
summary: "A tenant's secret **names**. Values cannot be read back.",
who: "tenant-or-operator",
deploy: false,
},
Route {
method: "put",
path: "/tenants/{id}/secrets/{name}",
summary: "Store a secret; the body is the value. Encrypted at rest.",
who: "operator",
deploy: true,
},
Route {
method: "delete",
path: "/tenants/{id}/secrets/{name}",
summary: "Forget a secret.",
who: "operator",
deploy: true,
},
Route {
method: "post",
path: "/tenants/{id}/tokens",
summary: "Mint a token for a tenant. The secret is in the answer, once.",
who: "operator",
deploy: true,
},
Route {
method: "get",
path: "/tokens",
summary: "Every token, hashes and all. Never a secret.",
who: "operator",
deploy: true,
},
Route {
method: "post",
path: "/tokens",
summary: "Mint an operator token. The secret is in the answer, once.",
who: "operator",
deploy: true,
},
Route {
method: "delete",
path: "/tokens/{id}",
summary: "Revoke a token, from the next request onwards.",
who: "operator",
deploy: true,
},
];
pub fn document() -> serde_json::Value {
let mut paths = serde_json::Map::new();
for route in ROUTES {
let entry = paths
.entry(route.path.to_string())
.or_insert_with(|| serde_json::json!({}));
let operation = serde_json::json!({
"summary": route.summary,
"operationId": operation_id(route),
"x-zygo-who": route.who,
"x-zygo-deploy": route.deploy,
"parameters": parameters(route.path),
"responses": {
"200": { "description": "the call succeeded" },
"default": {
"description": "a failure, as JSON with an `error` and often a `code`",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
},
},
});
entry[route.method] = operation;
}
serde_json::json!({
"openapi": "3.1.0",
"info": {
"title": "Zygo",
"summary": "Warm sandboxes over HTTP.",
"version": env!("CARGO_PKG_VERSION"),
"license": { "name": "Apache-2.0" },
},
"x-zygo-api": API_VERSION,
"servers": [{ "url": "http://127.0.0.1:7700" }],
"components": {
"securitySchemes": {
"bearer": {
"type": "http",
"scheme": "bearer",
"description": "An operator or tenant token. `zygo token mint`.",
},
},
"schemas": {
"Error": {
"type": "object",
"required": ["error"],
"properties": {
"error": { "type": "string" },
"code": { "type": "string" },
},
},
},
},
"security": [{ "bearer": [] }],
"paths": paths,
})
}
fn operation_id(route: &Route) -> String {
let mut out = route.method.to_string();
for part in route.path.split('/').filter(|p| !p.is_empty()) {
let word = part.trim_matches(['{', '}']);
let mut chars = word.chars();
if let Some(first) = chars.next() {
out.push(first.to_ascii_uppercase());
out.push_str(chars.as_str());
}
}
out
}
fn parameters(path: &str) -> Vec<serde_json::Value> {
path.split('/')
.filter(|p| p.starts_with('{') && p.ends_with('}'))
.map(|p| {
let name = p.trim_matches(['{', '}']);
serde_json::json!({
"name": name,
"in": "path",
"required": true,
"schema": { "type": "string" },
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn every_route_in_the_router_is_in_the_document() {
let source = include_str!("api.rs");
let documented: BTreeSet<String> = ROUTES
.iter()
.map(|r| format!("{} {}", r.method.to_uppercase(), r.path))
.collect();
let mut found = 0;
for line in source.lines() {
let Some(arm) = route_of(line) else { continue };
found += 1;
assert!(
documented.contains(&arm),
"`{arm}` is a route and is not in ROUTES — add it to \
cmd/openapi.rs, with a summary somebody writing a client \
could use"
);
}
assert!(
found > 30,
"only {found} routes were found in the source; the parser below \
has stopped matching the router's shape"
);
}
#[test]
fn nothing_is_documented_that_does_not_exist() {
let source = include_str!("api.rs");
let real: BTreeSet<String> = source.lines().filter_map(route_of).collect();
for route in ROUTES {
let arm = format!("{} {}", route.method.to_uppercase(), route.path);
assert!(
real.contains(&arm),
"`{arm}` is documented and the router has no such arm"
);
}
}
fn route_of(line: &str) -> Option<String> {
route_arm(line).or_else(|| early_route(line))
}
fn early_route(line: &str) -> Option<String> {
let line = line.trim();
let rest = line.strip_prefix("if req.method() == Method::")?;
let (method, rest) = rest.split_once(' ')?;
let path = rest.split('"').nth(1)?;
Some(format!("{method} {path}"))
}
fn route_arm(line: &str) -> Option<String> {
let line = line.trim();
let rest = line.strip_prefix("(&Method::")?;
let (method, rest) = rest.split_once(',')?;
let inside = rest.trim().strip_prefix('[')?.split(']').next()?;
let mut path = String::new();
for part in inside.split(',').map(str::trim).filter(|p| !p.is_empty()) {
path.push('/');
if let Some(literal) = part.strip_prefix('"') {
path.push_str(literal.trim_end_matches('"'));
} else {
path.push('{');
path.push_str(part);
path.push('}');
}
}
if path.contains("{..}") {
return None;
}
Some(format!("{method} {path}"))
}
#[test]
fn the_committed_document_matches_this_build() {
let committed: serde_json::Value =
serde_json::from_str(include_str!("../../../../spec/openapi.json"))
.expect("spec/openapi.json is not JSON");
let current = document();
let operations = |doc: &serde_json::Value| -> BTreeSet<String> {
doc["paths"]
.as_object()
.expect("paths")
.iter()
.flat_map(|(path, item)| {
item.as_object()
.expect("an item")
.keys()
.map(|method| format!("{} {path}", method.to_uppercase()))
.collect::<Vec<_>>()
})
.collect()
};
let was = operations(&committed);
let now = operations(¤t);
let removed: Vec<&String> = was.difference(&now).collect();
if !removed.is_empty() {
let before = committed["x-zygo-api"].as_u64().unwrap_or(0);
assert!(
u64::from(API_VERSION) > before,
"{removed:?} were removed from the API, which an older client \
cannot survive — raise API_VERSION above {before}"
);
}
assert_eq!(
current, committed,
"spec/openapi.json is not this build's document; regenerate it with \
`zygo api --openapi > spec/openapi.json` and read the diff"
);
}
#[test]
fn the_document_is_openapi_with_a_version_a_client_can_check() {
let doc = document();
assert_eq!(doc["openapi"], "3.1.0");
assert_eq!(doc["info"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(doc["x-zygo-api"], API_VERSION);
let logs = &doc["paths"]["/fn/{name}/logs"]["get"];
assert_eq!(logs["operationId"], "getFnNameLogs");
assert_eq!(logs["parameters"][0]["name"], "name");
assert_eq!(logs["parameters"][0]["in"], "path");
assert!(doc["paths"]["/tokens"]["get"].is_object());
assert!(doc["paths"]["/tokens"]["post"].is_object());
}
#[test]
fn every_route_says_who_may_call_it() {
for route in ROUTES {
assert!(
["any", "tenant-or-operator", "operator"].contains(&route.who),
"{} {} has `who = {}`",
route.method,
route.path,
route.who
);
assert!(
!route.summary.is_empty() && route.summary.ends_with('.'),
"{} {} needs a summary that is a sentence",
route.method,
route.path
);
assert!(
!route.deploy || route.who == "operator",
"{} {} needs deploy rights and is not marked operator-only",
route.method,
route.path
);
}
}
}