use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use kindling_service::AppendObservationOptions;
use kindling_types::{Capsule, Observation, Pin, RetrieveOptions, RetrieveResult};
use serde_json::{json, Value};
use crate::dto::{
AppendObservationRequest, CloseCapsuleRequest, CreatePinRequest, OpenCapsuleQuery,
OpenCapsuleRequest, PreCompactContextRequest, SessionStartContextRequest, DEFAULT_MAX_RESULTS,
};
use crate::error::ApiError;
use crate::inject::{format_pre_compact, format_session_start, local_offset_seconds};
use crate::state::AppState;
pub const PROJECT_HEADER: &str = "x-kindling-project";
pub const SESSION_HEADER: &str = "x-kindling-session";
fn project_root(headers: &HeaderMap) -> Result<String, ApiError> {
let value = headers
.get(PROJECT_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty());
match value {
Some(root) => Ok(root.to_string()),
None => Err(ApiError::BadRequest(format!(
"missing or empty {PROJECT_HEADER} header"
))),
}
}
pub async fn health(State(state): State<AppState>) -> Json<Value> {
let schema = kindling_store::schema_version();
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"schemaVersion": schema.version,
"projects": state.known_project_ids(),
}))
}
pub async fn open_capsule(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<OpenCapsuleRequest>,
) -> Result<(StatusCode, Json<Capsule>), ApiError> {
let root = project_root(&headers)?;
let svc = state.service_for(&root)?;
let capsule = {
let guard = svc.lock().expect("service mutex poisoned");
guard.open_capsule(req.into())?
};
Ok((StatusCode::CREATED, Json(capsule)))
}
pub async fn close_capsule(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
body: Option<Json<CloseCapsuleRequest>>,
) -> Result<Json<Capsule>, ApiError> {
let root = project_root(&headers)?;
let req = body.map(|Json(r)| r).unwrap_or_default();
let svc = state.service_for(&root)?;
let capsule = {
let guard = svc.lock().expect("service mutex poisoned");
guard.close_capsule(&id, req.into())?
};
Ok(Json(capsule))
}
pub async fn get_open_capsule(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<OpenCapsuleQuery>,
) -> Result<Json<Option<Capsule>>, ApiError> {
let root = project_root(&headers)?;
let session_id = query
.session_id
.or_else(|| {
headers
.get(SESSION_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
ApiError::BadRequest(format!(
"missing or empty sessionId (query param or {SESSION_HEADER} header)"
))
})?;
let svc = state.service_for(&root)?;
let capsule = {
let guard = svc.lock().expect("service mutex poisoned");
guard.get_open_capsule(&session_id)?
};
Ok(Json(capsule))
}
pub async fn append_observation(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<AppendObservationRequest>,
) -> Result<(StatusCode, Json<Observation>), ApiError> {
let root = project_root(&headers)?;
let options = AppendObservationOptions {
capsule_id: req.capsule_id,
validate: req.validate.unwrap_or(true),
};
let svc = state.service_for(&root)?;
let observation = {
let guard = svc.lock().expect("service mutex poisoned");
guard.append_observation(req.input, options)?
};
Ok((StatusCode::CREATED, Json(observation)))
}
pub async fn retrieve(
State(state): State<AppState>,
headers: HeaderMap,
Json(options): Json<RetrieveOptions>,
) -> Result<Json<RetrieveResult>, ApiError> {
let root = project_root(&headers)?;
let svc = state.service_for(&root)?;
let result = {
let guard = svc.lock().expect("service mutex poisoned");
guard.retrieve(options)?
};
Ok(Json(result))
}
pub async fn create_pin(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreatePinRequest>,
) -> Result<(StatusCode, Json<Pin>), ApiError> {
let root = project_root(&headers)?;
let svc = state.service_for(&root)?;
let pin = {
let guard = svc.lock().expect("service mutex poisoned");
guard.pin(req.into())?
};
Ok((StatusCode::CREATED, Json(pin)))
}
pub async fn unpin(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
let root = project_root(&headers)?;
let svc = state.service_for(&root)?;
{
let guard = svc.lock().expect("service mutex poisoned");
guard.unpin(&id)?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn forget_observation(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
use kindling_service::ServiceError;
use kindling_store::StoreError;
let root = project_root(&headers)?;
let svc = state.service_for(&root)?;
let result = {
let guard = svc.lock().expect("service mutex poisoned");
guard.forget(&id)
};
match result {
Ok(()) => Ok(StatusCode::NO_CONTENT),
Err(ServiceError::Store(StoreError::ObservationNotFound(_))) => {
Err(ApiError::NotFound(format!("observation {id} not found")))
}
Err(err) => Err(err.into()),
}
}
pub async fn session_start_context(
State(state): State<AppState>,
headers: HeaderMap,
body: Option<Json<SessionStartContextRequest>>,
) -> Result<Json<Value>, ApiError> {
let root = project_root(&headers)?;
let req = body.map(|Json(r)| r).unwrap_or_default();
let max_results = req.max_results.unwrap_or(DEFAULT_MAX_RESULTS);
let now = now_ms();
let svc = state.service_for(&root)?;
let ctx = {
let guard = svc.lock().expect("service mutex poisoned");
guard.session_start_context_at(&req.scope_ids, max_results, now)?
};
let offset = local_offset_seconds(now);
let additional = format_session_start(&ctx, offset);
Ok(Json(json!({ "additionalContext": additional })))
}
pub async fn pre_compact_context(
State(state): State<AppState>,
headers: HeaderMap,
body: Option<Json<PreCompactContextRequest>>,
) -> Result<Json<Value>, ApiError> {
let root = project_root(&headers)?;
let req = body.map(|Json(r)| r).unwrap_or_default();
let now = now_ms();
let svc = state.service_for(&root)?;
let ctx = {
let guard = svc.lock().expect("service mutex poisoned");
guard.pre_compact_context_at(&req.scope_ids, now)?
};
let additional = format_pre_compact(&ctx);
Ok(Json(json!({ "additionalContext": additional })))
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_millis() as i64
}