diff --git a/.gitignore b/.gitignore index d580600..9210dae 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ uteke_index.usearch .fusion/ progress.md .cora/ +scripts/demo-record.sh diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index 2baab8d..0d5b73a 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -16,9 +16,19 @@ use uteke_core::Uteke; use crate::context::{self, ApiRole, AuthResult, ReqCtx}; use crate::types::*; +/// Current API version constant — used by health and versioned routes. +const API_LATEST: &str = "v2"; +const API_VERSIONS: &[&str] = &["v1", "v2"]; + pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response>> { let method = req.method().clone(); - let path = req.url().to_string(); + let raw_path = req.url().to_string(); + + // ── API Versioning (#737): parse /api/vN/ prefix ──────────────────── + let (api_version, path) = match ApiVersion::from_path(&raw_path) { + Some((ver, stripped)) => (Some(ver), stripped.to_string()), + None => (None, raw_path.clone()), + }; // CORS preflight — no auth required if method == Method::Options { @@ -100,6 +110,8 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< version: env!("CARGO_PKG_VERSION"), memories: total, namespaces, + api_versions: Some(API_VERSIONS.to_vec()), + api_latest: Some(API_LATEST), }, ) } @@ -292,7 +304,16 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< // Prefer unified results when available (#531) match unified_result { - Some(Ok(results)) => ctx.ok_response_for(req, &results), + Some(Ok(results)) => { + if api_version == Some(ApiVersion::V1) { + // v1: flat format [{id, content, score, ...}] + let v1_results: Vec = + results.iter().map(to_v1_flat).collect(); + ctx.ok_response_for(req, &v1_results) + } else { + ctx.ok_response_for(req, &results) + } + } Some(Err(e)) => { error!("Unified search error: {e}"); ctx.error_response_for(req, 500, "Internal server error") diff --git a/crates/uteke-server/src/types.rs b/crates/uteke-server/src/types.rs index 889a634..9bb32a2 100644 --- a/crates/uteke-server/src/types.rs +++ b/crates/uteke-server/src/types.rs @@ -95,6 +95,69 @@ pub fn resolve_doc_id_update(req: &DocUpdateRequest) -> Result<&str, &'static st } } +// ── API Versioning (#737) ──────────────────────────────────────────────────── + +/// Supported API versions. Routes prefixed with `/api/vN/` are dispatched +/// according to this version. Unversioned routes alias to `Latest`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiVersion { + /// v0.7.x compatible format (flat recall results). + V1, + /// Current format (v0.8.x+, wrapped UnifiedSearchResult). + V2, +} + +impl ApiVersion { + /// Parse `/api/vN/` prefix from a path. Returns `(version, stripped_path)` on match, + /// or `None` if the path is not versioned. + pub fn from_path(path: &str) -> Option<(Self, &str)> { + if let Some(rest) = path.strip_prefix("/api/v1/") { + Some((Self::V1, rest)) + } else if let Some(rest) = path.strip_prefix("/api/v2/") { + Some((Self::V2, rest)) + } else { + None + } + } +} + +/// Convert a `UnifiedSearchResult` (v2) to the v1 flat format. +/// v1 consumers expect: `[{id, content, score, namespace, tags, ...}]` +/// instead of the v2 wrapped structure. +pub fn to_v1_flat(result: &uteke_core::memory::types::UnifiedSearchResult) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert( + "content".into(), + serde_json::Value::String(result.content.clone()), + ); + map.insert("score".into(), serde_json::json!(result.score)); + if let Some(id) = &result.memory_id { + map.insert("id".into(), serde_json::Value::String(id.clone())); + } + if let Some(ns) = &result.namespace { + map.insert("namespace".into(), serde_json::Value::String(ns.clone())); + } + if let Some(source) = &result.source { + map.insert("source".into(), serde_json::Value::String(source.clone())); + } + if !result.tags.is_empty() { + map.insert("tags".into(), serde_json::json!(result.tags)); + } + if let Some(meta) = &result.metadata { + map.insert("metadata".into(), meta.clone()); + } + if let Some(mt) = &result.memory_type { + map.insert("type".into(), serde_json::Value::String(mt.clone())); + } + if let Some(imp) = result.importance { + map.insert("importance".into(), serde_json::json!(imp)); + } + if let Some(pin) = result.pinned { + map.insert("pinned".into(), serde_json::json!(pin)); + } + serde_json::Value::Object(map) +} + // ── Types ─────────────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -198,6 +261,12 @@ pub struct HealthResponse { pub version: &'static str, pub memories: usize, pub namespaces: usize, + /// Supported API versions (#737). + #[serde(skip_serializing_if = "Option::is_none")] + pub api_versions: Option>, + /// Latest API version (#737). + #[serde(skip_serializing_if = "Option::is_none")] + pub api_latest: Option<&'static str>, } #[derive(Serialize)]