use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::core::{
CaseId, Decision, Delivery, InboundEvent, PolicyDecision, PolicyRequest, RunId, Task, TaskId,
};
use crate::journal::RecordKind;
use crate::runtime::Runtime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Caller {
pub actor: String,
pub roles: Vec<String>,
}
impl Caller {
pub fn new(actor: impl Into<String>, roles: Vec<String>) -> Self {
Self {
actor: actor.into(),
roles,
}
}
}
#[async_trait::async_trait]
pub trait Authenticator: Send + Sync + std::fmt::Debug {
async fn authenticate(&self, headers: &HeaderMap) -> Result<Caller, AuthError>;
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("no credentials were presented")]
Missing,
#[error("the credentials presented were not accepted")]
Rejected,
}
#[derive(Debug, thiserror::Error)]
pub enum ApiSetupError {
#[error(
"this runtime has no policy engine — an HTTP surface with no authorization \
layer grants every authenticated caller the whole plane; wire one (start \
with `DenyAll`) before opening a port"
)]
NoPolicy,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CancelRequest {
pub reason: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DecisionRequest {
pub approved: bool,
pub reason: String,
#[serde(default)]
pub amendment: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RunView {
pub run: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub waiting_for: Option<String>,
pub sealed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cancellation_requested_by: Option<String>,
pub records: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub case: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TaskView {
pub id: String,
pub run: String,
pub kind: String,
pub justification: Value,
pub priority: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub case: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub decidable_by_you: bool,
}
impl TaskView {
fn of(task: Task, caller: &Caller) -> Self {
Self {
decidable_by_you: task.may_decide(&caller.actor, &caller.roles),
assignee: task.assignee,
id: task.id.to_hex(),
run: task.run.to_string(),
kind: task.kind,
justification: serde_json::to_value(&task.justification).unwrap_or(Value::Null),
priority: task.priority.as_str().to_owned(),
state: task.state.as_str().to_owned(),
case: task.case.map(|c| c.to_string()),
due_at: task.due_at.and_then(|d| {
d.format(&time::format_description::well_known::Rfc3339)
.ok()
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Worklist {
pub tasks: Vec<TaskView>,
pub truncated: bool,
}
#[derive(Clone)]
pub struct Api {
runtime: Arc<Runtime>,
auth: Arc<dyn Authenticator>,
policy: Arc<dyn crate::core::PolicyEngine>,
limit: usize,
}
impl std::fmt::Debug for Api {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Api")
.field("policy", &self.policy.digest())
.field("limit", &self.limit)
.finish_non_exhaustive()
}
}
impl Api {
pub const DEFAULT_LIMIT: usize = 100;
pub fn new(runtime: Arc<Runtime>, auth: Arc<dyn Authenticator>) -> Result<Self, ApiSetupError> {
let policy = runtime.policy().ok_or(ApiSetupError::NoPolicy)?.clone();
Ok(Self {
runtime,
auth,
policy,
limit: Self::DEFAULT_LIMIT,
})
}
#[must_use]
pub const fn limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
pub fn router(self) -> Router {
Router::new()
.route("/runs/{run}", get(run_view))
.route("/runs/{run}/cancel", post(cancel_run))
.route("/tasks", get(worklist))
.route("/tasks/{task}", get(task_view))
.route("/tasks/{task}/claim", post(claim))
.route("/tasks/{task}/release", post(release))
.route("/tasks/{task}/decide", post(decide))
.route("/cases/{case}", get(case_view))
.route("/events", post(deliver))
.with_state(self)
}
async fn gate(
&self,
headers: &HeaderMap,
action: &str,
resource: &str,
) -> Result<Caller, ApiError> {
let caller = self.auth.authenticate(headers).await?;
let context = json!({ "roles": caller.roles });
let decision = self.policy.authorize(&PolicyRequest {
principal: &caller.actor,
action,
resource,
context: &context,
});
match decision {
PolicyDecision::Permit => Ok(caller),
PolicyDecision::Deny { reason } => Err(ApiError(StatusCode::FORBIDDEN, reason)),
}
}
}
pub mod action {
pub const RUN_READ: &str = "api:run.read";
pub const RUN_CANCEL: &str = "api:run.cancel";
pub const TASK_LIST: &str = "api:task.list";
pub const TASK_READ: &str = "api:task.read";
pub const TASK_CLAIM: &str = "api:task.claim";
pub const TASK_RELEASE: &str = "api:task.release";
pub const TASK_DECIDE: &str = "api:task.decide";
pub const CASE_READ: &str = "api:case.read";
pub const EVENT_DELIVER: &str = "api:event.deliver";
pub const ALL: &[&str] = &[
RUN_READ,
RUN_CANCEL,
TASK_LIST,
TASK_READ,
TASK_CLAIM,
TASK_RELEASE,
TASK_DECIDE,
CASE_READ,
EVENT_DELIVER,
];
}
struct ApiError(StatusCode, String);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(json!({ "error": self.1 }))).into_response()
}
}
impl From<AuthError> for ApiError {
fn from(e: AuthError) -> Self {
Self(StatusCode::UNAUTHORIZED, e.to_string())
}
}
fn bad(what: &str) -> ApiError {
ApiError(StatusCode::BAD_REQUEST, format!("not a {what} id"))
}
fn unavailable(what: &str) -> ApiError {
ApiError(
StatusCode::NOT_IMPLEMENTED,
format!("this plane has no {what} store"),
)
}
fn store_failed() -> ApiError {
ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"the store is unavailable".into(),
)
}
fn not_found(what: &str) -> ApiError {
ApiError(StatusCode::NOT_FOUND, format!("no such {what}"))
}
async fn run_view(
State(api): State<Api>,
headers: HeaderMap,
Path(run): Path<String>,
) -> Result<Json<RunView>, ApiError> {
api.gate(&headers, action::RUN_READ, &run).await?;
let id = RunId::parse(&run).map_err(|_| bad("run"))?;
let records = api
.runtime
.journal()
.read(id, 1)
.await
.map_err(|_| store_failed())?;
let Some(last) = records.last() else {
return Err(not_found("run"));
};
let (status, waiting_for, sealed) = match last.kind() {
RecordKind::RunSuspended { reason } => {
("suspended".to_owned(), Some(reason.to_string()), false)
}
RecordKind::RunSealed { outcome, .. } => (outcome.clone(), None, true),
_ => ("running".to_owned(), None, false),
};
let case = records
.iter()
.find_map(|r| r.body.case.map(|c| c.to_string()));
let cancellation_requested_by = api
.runtime
.cancellation(id)
.await
.map_err(|_| store_failed())?
.map(|c| c.actor);
Ok(Json(RunView {
run: id.to_string(),
status,
waiting_for,
sealed,
cancellation_requested_by,
records: records.len() as u64,
case,
}))
}
async fn cancel_run(
State(api): State<Api>,
headers: HeaderMap,
Path(run): Path<String>,
Json(body): Json<CancelRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let caller = api.gate(&headers, action::RUN_CANCEL, &run).await?;
let id = RunId::parse(&run).map_err(|_| bad("run"))?;
let fresh = api
.runtime
.request_cancel(id, &caller.actor, &body.reason)
.await
.map_err(|e| ApiError(StatusCode::CONFLICT, e.to_string()))?;
Ok((
StatusCode::ACCEPTED,
Json(json!({
"requested_by": caller.actor,
"recorded": fresh,
})),
))
}
async fn worklist(State(api): State<Api>, headers: HeaderMap) -> Result<Json<Worklist>, ApiError> {
let caller = api.gate(&headers, action::TASK_LIST, "*").await?;
let tasks = api.runtime.tasks().ok_or_else(|| unavailable("task"))?;
let mut queued = tasks
.queue(&caller.roles, api.limit + 1)
.await
.map_err(|_| store_failed())?;
let truncated = queued.len() > api.limit;
queued.truncate(api.limit);
Ok(Json(Worklist {
tasks: queued
.into_iter()
.map(|t| TaskView::of(t, &caller))
.collect(),
truncated,
}))
}
async fn task_view(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
) -> Result<Json<TaskView>, ApiError> {
let caller = api.gate(&headers, action::TASK_READ, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let tasks = api.runtime.tasks().ok_or_else(|| unavailable("task"))?;
let found = tasks
.task(id)
.await
.map_err(|_| store_failed())?
.ok_or_else(|| not_found("task"))?;
Ok(Json(TaskView::of(found, &caller)))
}
async fn claim(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
) -> Result<Json<TaskView>, ApiError> {
let caller = api.gate(&headers, action::TASK_CLAIM, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let tasks = api.runtime.tasks().ok_or_else(|| unavailable("task"))?;
let claimed = tasks
.claim(id, &caller.actor, &caller.roles)
.await
.map_err(|e| claim_refused(&e))?;
Ok(Json(TaskView::of(claimed, &caller)))
}
async fn release(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
) -> Result<StatusCode, ApiError> {
let caller = api.gate(&headers, action::TASK_RELEASE, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let tasks = api.runtime.tasks().ok_or_else(|| unavailable("task"))?;
tasks
.release(id, &caller.actor)
.await
.map_err(|e| claim_refused(&e))?;
Ok(StatusCode::NO_CONTENT)
}
fn claim_refused(e: &crate::case::ClaimError) -> ApiError {
use crate::case::ClaimError;
let status = match *e {
ClaimError::Excluded { .. } | ClaimError::WrongRole { .. } => StatusCode::FORBIDDEN,
ClaimError::NotFound(_) => StatusCode::NOT_FOUND,
ClaimError::AlreadyClaimed { .. }
| ClaimError::NotPending { .. }
| ClaimError::NotHeld { .. } => StatusCode::CONFLICT,
ClaimError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let detail = if status == StatusCode::INTERNAL_SERVER_ERROR {
"the store is unavailable".to_owned()
} else {
e.to_string()
};
ApiError(status, detail)
}
async fn decide(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
Json(body): Json<DecisionRequest>,
) -> Result<Json<Value>, ApiError> {
let caller = api.gate(&headers, action::TASK_DECIDE, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let decision = Decision {
approved: body.approved,
actor: caller.actor.clone(),
reason: body.reason,
amendment: body.amendment,
};
api.runtime
.decide_task(id, &decision, &caller.roles)
.await
.map_err(|e| match e {
crate::core::RuntimeError::PolicyDenied(_) => {
ApiError(StatusCode::FORBIDDEN, e.to_string())
}
other => ApiError(StatusCode::CONFLICT, other.to_string()),
})?;
Ok(Json(json!({
"decided_by": caller.actor,
"approved": decision.approved,
})))
}
async fn case_view(
State(api): State<Api>,
headers: HeaderMap,
Path(case): Path<String>,
) -> Result<Json<Value>, ApiError> {
api.gate(&headers, action::CASE_READ, &case).await?;
let id = CaseId::parse(&case).map_err(|_| bad("case"))?;
let cases = api.runtime.cases().ok_or_else(|| unavailable("case"))?;
let found = cases
.case(id)
.await
.map_err(|_| store_failed())?
.ok_or_else(|| not_found("case"))?;
let deadlines = cases.deadlines(id).await.map_err(|_| store_failed())?;
Ok(Json(json!({
"case": serde_json::to_value(&found).unwrap_or(Value::Null),
"deadlines": serde_json::to_value(&deadlines).unwrap_or(Value::Null),
})))
}
async fn deliver(
State(api): State<Api>,
headers: HeaderMap,
Json(event): Json<InboundEvent>,
) -> Result<Json<Value>, ApiError> {
api.gate(&headers, action::EVENT_DELIVER, &event.kind)
.await?;
let delivery = api
.runtime
.deliver(&event)
.await
.map_err(|e| ApiError(StatusCode::CONFLICT, e.to_string()))?;
Ok(Json(match delivery {
Delivery::Resumed { run } => json!({ "delivery": "resumed", "run": run.to_string() }),
Delivery::Buffered => json!({ "delivery": "buffered" }),
Delivery::Duplicate => json!({ "delivery": "duplicate" }),
}))
}