use std::sync::{Arc, OnceLock};
use axum::http::Method;
use axum::middleware::from_fn_with_state;
use axum::routing::{delete, get, post, put, MethodRouter};
use axum::Router;
use schemars::generate::SchemaSettings;
use schemars::{JsonSchema, SchemaGenerator};
use serde_json::{json, Map, Value};
use crate::auth::Scope;
use crate::tenants::AppState;
use crate::{admin, documents, memory, middleware, ops, types};
type SchemaFn = fn(&mut SchemaGenerator) -> Value;
fn schema<T: JsonSchema>(g: &mut SchemaGenerator) -> Value {
serde_json::to_value(g.subschema_for::<T>()).expect("schema serializes")
}
fn memory_object(_: &mut SchemaGenerator) -> Value {
json!({"type": "object", "description": "See docs/memory-api/README.md for the request and response fields."})
}
fn doc_array(g: &mut SchemaGenerator) -> Value {
serde_json::to_value(g.subschema_for::<Vec<documents::DocIn>>()).expect("schema serializes")
}
#[derive(Clone, Copy)]
pub enum Request {
None,
Json(SchemaFn),
OptionalEmptyObject,
Raw(&'static str, &'static str),
}
#[derive(Clone, Copy)]
pub enum Reply {
Json(SchemaFn),
Raw(&'static str, &'static str),
}
pub struct Param {
pub name: &'static str,
pub location: &'static str,
pub description: &'static str,
pub kind: &'static str,
}
pub struct Op {
pub method: &'static str,
pub path: &'static str,
pub id: &'static str,
pub tag: &'static str,
pub summary: &'static str,
pub scope: Option<Scope>,
pub tenant: bool,
pub request: Request,
pub status: u16,
pub reply: Reply,
pub params: &'static [Param],
pub handler: fn() -> MethodRouter<Arc<AppState>>,
}
const LIST_PARAMS: &[Param] = &[
Param {
name: "limit",
location: "query",
description: "Page size, 1..=1000 (default 100).",
kind: "integer",
},
Param {
name: "after",
location: "query",
description: "The next_cursor of the previous page.",
kind: "string",
},
];
const IDEMPOTENCY: &[Param] = &[Param {
name: "Idempotency-Key",
location: "header",
description:
"Replays the recorded response for a retried request (1..=128 visible ASCII characters).",
kind: "string",
}];
const PURGE: &[Param] = &[Param {
name: "purge",
location: "query",
description: "true also rewrites the journal and scrubs what the deleted document left behind.",
kind: "boolean",
}];
const CONDITIONAL: &[Param] = &[Param {
name: "If-None-Match",
location: "header",
description: "Return 304 when the ETag still matches.",
kind: "string",
}];
pub static OPS: &[Op] = &[
Op { method: "get", path: "/healthz", id: "healthz", tag: "operations", summary: "Liveness: the process is serving.", scope: None, tenant: false, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Health>), params: &[], handler: || get(ops::healthz) },
Op { method: "get", path: "/readyz", id: "readyz", tag: "operations", summary: "Readiness: 200 while accepting work, 503 while starting or draining.", scope: None, tenant: false, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Health>), params: &[], handler: || get(ops::readyz) },
Op { method: "get", path: "/metrics", id: "metrics", tag: "operations", summary: "Prometheus metrics. Needs the admin scope unless --metrics-public is set.", scope: Some(Scope::Admin), tenant: false, request: Request::None, status: 200, reply: Reply::Raw("text/plain", "Prometheus text exposition format 0.0.4"), params: &[], handler: || get(ops::metrics) },
Op { method: "get", path: "/openapi.json", id: "openapi", tag: "operations", summary: "This document.", scope: None, tenant: false, request: Request::None, status: 200, reply: Reply::Raw("application/json", "OpenAPI 3.1 document"), params: &[], handler: || get(ops::openapi) },
Op { method: "post", path: "/api/v1/documents", id: "addDocument", tag: "documents", summary: "Store or replace one document.", scope: Some(Scope::Write), tenant: true, request: Request::Json(schema::<documents::DocIn>), status: 200, reply: Reply::Json(schema::<types::Added>), params: IDEMPOTENCY, handler: || post(documents::add_one) },
Op { method: "get", path: "/api/v1/documents", id: "listDocuments", tag: "documents", summary: "List document ids and metadata, paginated by id cursor.", scope: Some(Scope::Read), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::DocumentPage>), params: LIST_PARAMS, handler: || get(documents::list_documents) },
Op { method: "post", path: "/api/v1/documents/batch", id: "addDocuments", tag: "documents", summary: "Store or replace up to --max-batch documents in one atomic commit.", scope: Some(Scope::Write), tenant: true, request: Request::Json(doc_array), status: 200, reply: Reply::Json(schema::<types::Added>), params: IDEMPOTENCY, handler: || post(documents::add_batch) },
Op { method: "post", path: "/api/v1/documents/stream", id: "streamDocuments", tag: "documents", summary: "Bulk ingest newline-delimited documents, committed in batches; not atomic across batches.", scope: Some(Scope::Write), tenant: true, request: Request::Raw("application/x-ndjson", "One document object per line"), status: 200, reply: Reply::Json(schema::<types::StreamResult>), params: &[], handler: || post(documents::ingest_stream) },
Op { method: "delete", path: "/api/v1/documents/{id}", id: "deleteDocument", tag: "documents", summary: "Delete a document by id (percent-encoded).", scope: Some(Scope::Write), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Deleted>), params: PURGE, handler: || delete(documents::delete_doc) },
Op { method: "post", path: "/api/v1/query", id: "query", tag: "documents", summary: "Nearest documents to a vector, best first, with exact cosine scores.", scope: Some(Scope::Read), tenant: true, request: Request::Json(schema::<documents::QueryIn>), status: 200, reply: Reply::Json(schema::<types::Matches>), params: &[], handler: || post(documents::query) },
Op { method: "post", path: "/api/v1/search", id: "search", tag: "documents", summary: "Text search: document ids and scores, best first (BM25, or the on-device model stages when configured); stored text and metadata on request.", scope: Some(Scope::Read), tenant: true, request: Request::Json(schema::<documents::SearchIn>), status: 200, reply: Reply::Json(schema::<types::SearchHits>), params: &[], handler: || post(documents::search) },
Op { method: "get", path: "/api/v1/stats", id: "stats", tag: "documents", summary: "Document and vector counts and storage use for the tenant.", scope: Some(Scope::Read), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Stats>), params: CONDITIONAL, handler: || get(documents::stats) },
Op { method: "post", path: "/api/v1/memory/remember", id: "memoryRemember", tag: "memory", summary: "Store one fact or an array of facts with validity time, source and supersession. See docs/memory-api.", scope: Some(Scope::Write), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::remember) },
Op { method: "post", path: "/api/v1/memory/recall", id: "memoryRecall", tag: "memory", summary: "Retrieve facts for a query, honoring validity time and supersession. See docs/memory-api.", scope: Some(Scope::Read), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::recall) },
Op { method: "post", path: "/api/v1/memory/explain", id: "memoryExplain", tag: "memory", summary: "Recall with the evidence behind each result and its supersession chain. See docs/memory-api.", scope: Some(Scope::Read), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::explain) },
Op { method: "post", path: "/api/v1/memory/forget", id: "memoryForget", tag: "memory", summary: "Forget facts by id, source or scope. See docs/memory-api.", scope: Some(Scope::Write), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::forget) },
Op { method: "post", path: "/api/v1/memory/get", id: "memoryGet", tag: "memory", summary: "Fetch one fact by id. See docs/memory-api.", scope: Some(Scope::Read), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::get) },
Op { method: "post", path: "/api/v1/memory/history", id: "memoryHistory", tag: "memory", summary: "A fact's version and supersession history. See docs/memory-api.", scope: Some(Scope::Read), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::history) },
Op { method: "post", path: "/api/v1/memory/applyForgetting", id: "memoryApplyForgetting", tag: "memory", summary: "Apply the configured forgetting policy. See docs/memory-api.", scope: Some(Scope::Write), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::apply_forgetting) },
Op { method: "post", path: "/api/v1/memory/consolidate", id: "memoryConsolidate", tag: "memory", summary: "Run the configured consolidation policy. See docs/memory-api.", scope: Some(Scope::Write), tenant: true, request: Request::Json(memory_object), status: 200, reply: Reply::Json(memory_object), params: IDEMPOTENCY, handler: || post(memory::consolidate) },
Op { method: "post", path: "/api/v1/index/maintain", id: "maintainIndex", tag: "index", summary: "Train the tenant's document index over stored embeddings.", scope: Some(Scope::Write), tenant: true, request: Request::OptionalEmptyObject, status: 200, reply: Reply::Json(schema::<types::Maintained>), params: &[], handler: || post(documents::maintain_index) },
Op { method: "get", path: "/api/v1/compaction", id: "compactionStatus", tag: "index", summary: "Compaction status and schedule for the tenant.", scope: Some(Scope::Read), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::CompactionStatusBody>), params: &[], handler: || get(admin::compaction_status) },
Op { method: "post", path: "/api/v1/compaction", id: "runCompaction", tag: "index", summary: "Compact the tenant's store now.", scope: Some(Scope::Admin), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::CompactionRun>), params: &[], handler: || post(admin::run_compaction) },
Op { method: "post", path: "/api/v1/snapshots", id: "createSnapshot", tag: "snapshots", summary: "Snapshot the tenant's store on the server (the tenant pauses while it is copied).", scope: Some(Scope::Admin), tenant: true, request: Request::None, status: 201, reply: Reply::Json(schema::<types::Snapshot>), params: &[], handler: || post(admin::create_snapshot) },
Op { method: "get", path: "/api/v1/snapshots", id: "listSnapshots", tag: "snapshots", summary: "List the tenant's snapshots.", scope: Some(Scope::Admin), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Snapshots>), params: &[], handler: || get(admin::list_snapshots) },
Op { method: "delete", path: "/api/v1/snapshots/{id}", id: "deleteSnapshot", tag: "snapshots", summary: "Delete a snapshot.", scope: Some(Scope::Admin), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Deleted>), params: &[], handler: || delete(admin::delete_snapshot) },
Op { method: "get", path: "/api/v1/snapshots/{id}/archive", id: "downloadSnapshot", tag: "snapshots", summary: "Download a snapshot as a tar stream (the off-box backup format).", scope: Some(Scope::Admin), tenant: true, request: Request::None, status: 200, reply: Reply::Raw("application/x-tar", "Uncompressed tar of the store directory"), params: &[], handler: || get(admin::download_snapshot) },
Op { method: "post", path: "/api/v1/snapshots/{id}/restore", id: "restoreSnapshot", tag: "snapshots", summary: "Replace the tenant's store with a snapshot; the replaced store is kept as a snapshot.", scope: Some(Scope::Admin), tenant: true, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Restored>), params: &[], handler: || post(admin::restore_snapshot) },
Op { method: "put", path: "/api/v1/restore", id: "restoreUpload", tag: "snapshots", summary: "Replace the tenant's store with an uploaded snapshot archive after verifying it.", scope: Some(Scope::Admin), tenant: true, request: Request::Raw("application/x-tar", "Tar produced by the archive endpoint"), status: 200, reply: Reply::Json(schema::<types::Restored>), params: &[], handler: || put(admin::restore_upload) },
Op { method: "post", path: "/api/v1/admin/keys/reload", id: "reloadKeys", tag: "admin", summary: "Re-read --keys-file. Needs an all-tenant admin key.", scope: Some(Scope::Admin), tenant: false, request: Request::None, status: 200, reply: Reply::Json(schema::<types::KeysReloaded>), params: &[], handler: || post(admin::reload_keys) },
Op { method: "post", path: "/api/v1/admin/tls/reload", id: "reloadTls", tag: "admin", summary: "Re-read --tls-cert and --tls-key. Needs an all-tenant admin key.", scope: Some(Scope::Admin), tenant: false, request: Request::None, status: 200, reply: Reply::Json(schema::<types::TlsReloaded>), params: &[], handler: || post(admin::reload_tls) },
Op { method: "get", path: "/api/v1/admin/tenants", id: "listTenants", tag: "admin", summary: "Tenants with a store on disk. Needs an all-tenant admin key.", scope: Some(Scope::Admin), tenant: false, request: Request::None, status: 200, reply: Reply::Json(schema::<types::Tenants>), params: &[], handler: || get(admin::list_tenants) },
];
fn find_op(method: &str, path: &str) -> Option<&'static Op> {
OPS.iter()
.filter(|op| op.method.eq_ignore_ascii_case(method) && template_matches(op.path, path))
.min_by_key(|op| op.path.matches('{').count())
}
fn template_matches(template: &str, path: &str) -> bool {
let (mut t, mut p) = (template.split('/'), path.split('/'));
loop {
match (t.next(), p.next()) {
(None, None) => return true,
(Some(t), Some(p)) if t.starts_with('{') => {
if p.is_empty() {
return false;
}
}
(Some(t), Some(p)) if t == p => {}
_ => return false,
}
}
}
pub fn route_label(path: &str) -> &'static str {
OPS.iter()
.filter(|op| template_matches(op.path, path))
.min_by_key(|op| op.path.matches('{').count())
.map_or("unmatched", |op| op.path)
}
pub fn is_public(method: &Method, path: &str) -> bool {
find_op(method.as_str(), path).is_some_and(|op| op.scope.is_none())
}
pub fn route_templates() -> impl Iterator<Item = &'static str> {
let mut seen = std::collections::BTreeSet::new();
OPS.iter()
.map(|op| op.path)
.filter(move |p| seen.insert(*p))
}
pub fn router(state: Arc<AppState>) -> Router {
let mut router = Router::new();
for op in OPS {
router = router.route(op.path, (op.handler)());
}
router
.fallback(middleware::not_found)
.method_not_allowed_fallback(middleware::method_not_allowed)
.layer(from_fn_with_state(state.clone(), middleware::auth))
.layer(from_fn_with_state(state.clone(), middleware::cors))
.layer(from_fn_with_state(state.clone(), middleware::observe))
.with_state(state)
}
fn json_content(content_type: &str, schema: Value) -> Value {
json!({ content_type: { "schema": schema } })
}
pub fn openapi() -> &'static Value {
static SPEC: OnceLock<Value> = OnceLock::new();
SPEC.get_or_init(build_openapi)
}
fn build_openapi() -> Value {
let mut settings = SchemaSettings::draft2020_12();
settings.definitions_path = "/components/schemas".into();
settings.meta_schema = None;
let mut generator = SchemaGenerator::new(settings);
let problem = schema::<types::Problem>(&mut generator);
let mut paths: Map<String, Value> = Map::new();
for op in OPS {
let mut parameters = Vec::new();
if op.tenant {
parameters.push(json!({
"name": "X-Tenant-ID", "in": "header", "required": false,
"description": "Tenant whose store is used: 1..=64 of [A-Za-z0-9_-]. Defaults to the key's only tenant, else \"default\".",
"schema": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$"}
}));
}
if op.path.contains("{id}") {
parameters.push(json!({
"name": "id", "in": "path", "required": true,
"description": if op.path.contains("snapshots") { "Snapshot id." } else { "Document id, percent-encoded." },
"schema": {"type": "string"}
}));
}
for p in op.params {
parameters.push(json!({
"name": p.name, "in": p.location, "required": false,
"description": p.description, "schema": {"type": p.kind}
}));
}
let mut operation = Map::new();
operation.insert("operationId".into(), op.id.into());
operation.insert("summary".into(), op.summary.into());
operation.insert("tags".into(), json!([op.tag]));
operation.insert("parameters".into(), Value::Array(parameters));
match op.request {
Request::None => {}
Request::Json(f) => {
operation.insert(
"requestBody".into(),
json!({"required": true, "content": json_content("application/json", f(&mut generator))}),
);
}
Request::OptionalEmptyObject => {
operation.insert(
"requestBody".into(),
json!({"required": false, "content": json_content("application/json", json!({"type": "object", "maxProperties": 0}))}),
);
}
Request::Raw(content_type, description) => {
operation.insert(
"requestBody".into(),
json!({"required": true, "description": description, "content": json_content(content_type, json!({"type": "string", "format": "binary"}))}),
);
}
}
let success = match op.reply {
Reply::Json(f) => {
json!({"description": "Success", "content": json_content("application/json", f(&mut generator))})
}
Reply::Raw(content_type, description) => {
let schema = if content_type == "application/json" {
json!({"type": "object"})
} else {
json!({"type": "string"})
};
json!({"description": description, "content": json_content(content_type, schema)})
}
};
let mut responses = Map::new();
responses.insert(op.status.to_string(), success);
if matches!(op.id, "stats" | "listDocuments") {
responses.insert(
"304".into(),
json!({"description": "ETag matched If-None-Match"}),
);
}
responses.insert(
"default".into(),
json!({"description": "Error (RFC 9457 problem details)", "content": json_content("application/problem+json", problem.clone())}),
);
operation.insert("responses".into(), Value::Object(responses));
operation.insert(
"security".into(),
match op.scope {
None => json!([]),
Some(scope) => json!([{ "bearerAuth": [scope.name()] }]),
},
);
paths
.entry(op.path.to_string())
.or_insert_with(|| json!({}))
.as_object_mut()
.expect("path item is an object")
.insert(op.method.into(), Value::Object(operation));
}
let mut schemas = generator.take_definitions(true);
schemas.remove("$schema");
json!({
"openapi": "3.1.0",
"info": {
"title": "hms-server",
"version": env!("CARGO_PKG_VERSION"),
"summary": "Holographic Memory HTTP service",
"description": "One engine store per tenant. The /api/v1 contract is stable: see docs/server/versioning.md. Errors are RFC 9457 problem details.",
"license": {"name": "Apache-2.0", "identifier": "Apache-2.0"}
},
"servers": [{"url": "/"}],
"tags": [
{"name": "documents"}, {"name": "memory"}, {"name": "index"}, {"name": "snapshots"},
{"name": "admin"}, {"name": "operations"}
],
"paths": paths,
"components": {
"schemas": schemas,
"securitySchemes": {
"bearerAuth": {
"type": "http", "scheme": "bearer",
"description": "API key. Scopes: read, write, admin (admin includes read and write). Keys are bound to tenants."
}
}
}
})
}