From 275428445f25940f1ba824c4c8c983d15a3c8d54 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Sat, 18 Jul 2026 10:01:12 +0700 Subject: [PATCH] feat(api): URL prefix versioning /api/v1 and /api/v2 (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds API versioning middleware to uteke-server. Changes: - ApiVersion enum (V1, V2) with path prefix parser - /api/v1/* routes return v1 format (flat recall results for backward compat) - /api/v2/* routes return current v2 format (wrapped UnifiedSearchResult) - Unversioned routes (/recall, /remember, etc.) continue to work (→ latest) - Health endpoint now includes api_versions and api_latest fields - to_v1_flat() adapter converts UnifiedSearchResult → v1 flat format v1 vs v2 differences: - v1 recall: [{id, content, score, namespace, tags, ...}] (flat) - v2 recall: [{memory: {id, content, ...}, score}] (wrapped) Routes affected: - /recall → /api/v1/recall (flat) or /api/v2/recall (wrapped) - /remember, /memory, /forget, /list, /search, /stats, etc. - /room/*, /doc/* — all sub-routes versioned - /health — unversioned, includes version info --- .gitignore | 1 + crates/uteke-server/src/handlers.rs | 25 ++++++++++- crates/uteke-server/src/types.rs | 69 +++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d580600f..9210daed 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 2baab8d6..0d5b73ac 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 889a634a..9bb32a26 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)]