use serde_json::{Map, Value, json};
use super::{PAGES, PageRoute, View};
pub fn print() -> anyhow::Result<String> {
Ok(serde_json::to_string_pretty(&document())?)
}
pub(super) async fn serve() -> axum::Json<Value> {
axum::Json(document())
}
pub(super) fn document() -> Value {
let mut paths = Map::<String, Value>::new();
for route in PAGES.iter().filter(|route| route.serves_api()) {
let (parameters, schema) = operation(route);
paths.entry(route.api).or_insert_with(|| json!({}))
[route.method.as_str().to_ascii_lowercase()] = json!({
"description": format!("CLI twins: {}. The required scope is declared in x-scope.", route.cli.join("; ")),
"x-scope": route.scope,
"x-cli-twins": route.cli,
"parameters": parameters,
"responses": {
"200": {
"description": "The CLI twin's JSON response, filtered by the caller's scopes.",
"content": {"application/json": {"schema": schema}}
},
"400": refusal("Invalid path argument."),
"401": refusal("A valid Console sign-in or bearer token is required."),
"403": refusal("The caller lacks the required scope."),
"422": refusal("The CLI twin refused the arguments or selected record."),
"429": {
"description": "Too many failed authentications from this client address.",
"headers": {"Retry-After": {
"description": "Seconds until another authentication may be attempted.",
"schema": {"type": "integer", "minimum": 1}
}},
"content": {"application/json": {"schema": object(json!({
"error": {"type": "string"},
"retry_after": {"type": "integer", "minimum": 1}
}))}}
},
"500": refusal("The Console could not read this view.")
}
});
if route.method == axum::http::Method::POST {
paths.get_mut(route.api).unwrap()["post"]["requestBody"] = action_body(route.view);
}
}
json!({
"openapi": "3.1.1",
"info": {
"title": "thirdshift Console API",
"version": env!("CARGO_PKG_VERSION"),
"description": "Currently served /api/v1 operations. Each operation enforces its x-scope. Bearer tokens require HTTPS or a direct connection to 127.0.0.1 without proxy headers. Local --no-sign-in needs no session."
},
"paths": paths,
"security": [{"ConsoleSession": []}, {"ConsoleBearer": []}],
"components": {"securitySchemes": {
"ConsoleSession": {
"type": "apiKey", "in": "cookie", "name": "__Host-thirdshift-session"
},
"ConsoleBearer": {"type": "http", "scheme": "bearer"}
}}
})
}
fn refusal(description: &str) -> Value {
json!({"description": description, "content": {"application/json": {
"schema": object(json!({"error": {"type": "string"}}))
}}})
}
fn parameter(name: &str, location: &str, required: bool, schema: Value) -> Value {
json!({"name": name, "in": location, "required": required, "schema": schema})
}
fn object(properties: Value) -> Value {
let required = properties
.as_object()
.expect("schema properties")
.keys()
.collect::<Vec<_>>();
json!({"type": "object", "properties": properties, "required": required})
}
fn array(items: Value) -> Value {
json!({"type": "array", "items": items})
}
fn operation(route: &PageRoute) -> (Vec<Value>, Value) {
let mut parameters: Vec<_> = route
.api
.split('/')
.filter_map(|segment| {
let name = segment.strip_prefix('{')?.strip_suffix('}')?;
Some(parameter(name, "path", true, json!({"type": "string"})))
})
.collect();
parameters.push(json!({
"name": "X-Thirdshift-Person", "in": "header", "required": false,
"description": "Bearer calls only: an address on the owning machine's sign-in list. Effective scopes intersect the token's and this address's scopes.",
"schema": {"type": "string", "format": "email"}
}));
let mut query = |name, required, schema| {
parameters.push(parameter(name, "query", required, schema));
};
let text = json!({"type": "string"});
let integer = json!({"type": "integer"});
let record = json!({"type": "object"});
let version = json!({"type": "integer", "const": 1});
let schema = match route.view {
View::Whoami => object(json!({
"version": version, "hostname": text, "thirdshift_version": text,
"api_version": {"type": "string", "const": "v1"},
"identity": {"oneOf": [
{"type": "object", "required": ["kind", "holder"], "properties": {
"kind": {"const": "token"}, "holder": text, "person": text
}},
object(json!({"kind": {"const": "session"}, "address": text})),
object(json!({"kind": {"const": "local"}, "uid": integer}))
]},
"scopes": array(json!({"type": "string", "enum": super::Scope::ALL}))
})),
View::OpenApi => object(json!({
"openapi": {"type": "string", "const": "3.1.1"},
"info": record, "paths": record, "components": record, "security": array(record.clone())
})),
View::Inbox if route.path == "/waterfall" => {
query("command", false, text);
tree()
}
View::Inbox => {
if route.path == "/" {
query("all", false, json!({"type": "boolean", "default": false}));
}
object(json!({
"version": version, "hostname": text, "active_runs": integer,
"commands": array(record.clone()), "idle_passes": array(record.clone()),
"pass_starts": array(record), "github_answer_age_seconds": {"type": ["integer", "null"]},
"github_last_answered_at": {"type": ["string", "null"]},
"github_failure": {"type": ["string", "null"]}
}))
}
View::Inspect => {
query("command", true, text.clone());
object(json!({"version": version, "hostname": text, "command": record}))
}
View::Spans => {
query("command", false, text.clone());
query("span", false, text);
json!({"oneOf": [tree(), span()]})
}
View::Log => {
query("command", true, text.clone());
query("session", false, text.clone());
object(json!({"version": version, "contents": text}))
}
View::Reader => object(json!({"version": version, "contents": text})),
View::Logs | View::CommandLogs => object(json!({
"version": version, "logs": array(object(json!({
"id": text, "name": text, "repository": text, "kind": text,
"live": {"type": "boolean"}, "command": text, "owners": array(text.clone())
})))
})),
View::Metrics => {
for name in ["command", "from", "through"] {
query(name, false, text.clone());
}
query("repo", false, array(text.clone()));
json!({"oneOf": [
object(json!({"version": version, "hostname": text, "command": record})),
object(json!({"version": version, "hostname": text, "weeks": array(record)}))
]})
}
View::BugOrigins => array(object(json!({
"issue": text, "closed_at": text, "state": {"enum": ["proposed", "confirmed", "rejected"]},
"commit": {"type": ["string", "null"]}, "reason": text, "pull_requests": array(record)
}))),
View::Scores => {
query("pull_request", false, text.clone());
array(object(json!({
"week": text, "window_start": text, "window_end": text,
"seed": integer, "sample": array(record), "initial_calibration": {"type": "boolean"}
})))
}
View::Experiments => array(experiment()),
View::Experiment => experiment(),
View::ExperimentSession => object(json!({
"id": text, "trace": {"type": "boolean"}, "contents": text
})),
View::CronJobs => object(json!({
"jobs": array(object(json!({"schedule": text, "command": text}))), "pass_starts": array(record)
})),
View::Resources | View::ResourceStatus => object(json!({
"host": object(json!({"total_memory_mb": integer, "available_memory_mb": integer,
"logical_cpus": integer, "load_one": {"type": "number"}})),
"limits": object(json!({"min_available_memory_mb": integer, "max_load_per_cpu_percent": integer})),
"load_per_cpu_percent": {"type": "number"}, "ready": {"type": "boolean"},
"reasons": array(text)
})),
View::ConsoleLog => array(object(
json!({"time": integer, "email": text, "event": text}),
)),
View::ThemeImport | View::ThemeExport => object(json!({
"name": text, "base": text, "tokens": {"type": "object", "additionalProperties": text}
})),
View::Themes => array(text),
View::Stop => object(json!({"version": version, "name": text, "outcome": text,
"issue_branches": array(text.clone()), "pull_requests": array(text)})),
View::Dismiss | View::UndoDismissal => record,
View::HumanScores => array(record),
View::SettleBugOrigin => record,
View::ApproveEvalTask => {
object(json!({"suite": text, "task": text, "state": {"const": "approved"}}))
}
View::Look | View::Theme | View::Sections => record,
View::Changes
| View::LogStream
| View::Account
| View::SignOut
| View::SignInPage
| View::SignIn
| View::StopPage
| View::SetPasswordPage
| View::SendCode
| View::SetPassword => unreachable!("page-only operation"),
};
(parameters, schema)
}
fn action_body(view: View) -> Value {
let text = json!({"type": "string"});
let (properties, required) = match view {
View::ThemeImport => {
return json!({"required": true, "content": {"application/json": {"schema": {
"type": "object", "required": ["name"], "properties": {
"name": text, "base": text,
"tokens": {"type": "object", "additionalProperties": text}
}
}}}});
}
View::Stop => (
json!({"command": text, "requeue": {"type":"boolean", "default":false}}),
vec!["command"],
),
View::Dismiss | View::UndoDismissal => (json!({"command": text}), vec!["command"]),
View::Theme => (json!({"theme": text}), vec!["theme"]),
View::Sections => (json!({"hide": text, "show": text}), vec![]),
View::SettleBugOrigin => (
json!({"issue": text, "action": {"enum":["confirm","correct","reject"]}, "pull_request": text}),
vec!["issue", "action"],
),
View::HumanScores => {
let mut properties = json!({"pull_request": text});
let mut required = vec!["pull_request".to_owned()];
for criterion in crate::scoring::Criterion::ALL {
let name = criterion.name();
properties[name] = json!({"enum":["pass","fail"]});
properties[format!("{name}_reason")] = text.clone();
required.push(name.to_owned());
required.push(format!("{name}_reason"));
}
return json!({"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{
"type":"object", "properties":properties,"required":required
}}}});
}
View::ApproveEvalTask => (json!({}), vec![]),
View::Whoami
| View::OpenApi
| View::BugOrigins
| View::Scores
| View::Experiments
| View::Experiment
| View::ExperimentSession
| View::CronJobs
| View::Resources
| View::ResourceStatus
| View::Metrics
| View::Inbox
| View::Inspect
| View::Spans
| View::Log
| View::Changes
| View::Logs
| View::CommandLogs
| View::Reader
| View::LogStream
| View::ConsoleLog
| View::Account
| View::SignOut
| View::SignInPage
| View::SignIn
| View::Look
| View::Themes
| View::ThemeExport
| View::StopPage
| View::SetPasswordPage
| View::SendCode
| View::SetPassword => unreachable!("not a served action"),
};
json!({"required":!required.is_empty(),"content":{"application/x-www-form-urlencoded":{"schema":{
"type":"object", "properties":properties,"required":required
}}}})
}
fn tree() -> Value {
object(
json!({"command": {"type": "string"}, "now": {"type": "number"}, "spans": array(span())}),
)
}
fn span() -> Value {
object(json!({
"id": {"type": "string"}, "parent_id": {"type": ["string", "null"]},
"kind": {"type": "string"}, "name": {"type": "string"},
"start": {"type": ["number", "null"]}, "end": {"type": ["number", "null"]},
"duration_ms": {"type": ["number", "null"]}, "status": {"type": "string"},
"error": {"type": ["string", "null"]}, "estimated": {"type": "boolean"},
"attributes": {"type": "object"}, "input": {"type": ["string", "null"]},
"output": {"type": ["string", "null"]}, "command": {"type": "string"},
"command_url": {"type": "string"}, "conversation_url": {"type": ["string", "null"]},
"run": {"type": ["integer", "null"]}, "session_kind": {"type": ["string", "null"]}
}))
}
fn experiment() -> Value {
object(json!({
"format": {"type": "integer", "const": 4}, "id": {"type": "string"},
"name": {"type": "string"}, "state": {"type": "string"}, "created_at": {"type": "string"},
"tasks": array(json!({"type": "object"})), "results": array(json!({"type": "object"})),
"sessions": array(json!({"type": "object"})), "running_trials": array(json!({"type": "object"}))
}))
}