//! Shared request/response DTOs and result-mapping for the data routes. //! //! The standalone router ([`crate::router`]) and the cluster router //! ([`crate::cluster`]) expose the SAME data surface (`/items`, //! `/embeddings`, `/signals`, `/feed`, `/search`). Their request/response //! shapes and the engine-result → JSON mapping were previously duplicated //! verbatim across both modules; any drift between the two (e.g. a new field //! added on one side only) would silently change one mode's API. This module //! is the single source of truth so the two routers cannot diverge. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use tidaldb::query::{retrieve::RetrieveResult, search::SearchResultItem}; use utoipa::ToSchema; // ── Request DTOs ───────────────────────────────────────────────────────────── /// `POST /items` body. #[derive(Debug, Deserialize, ToSchema)] pub struct ItemRequest { /// Entity ID of the item to create. #[schema(example = 1)] pub entity_id: u64, /// Arbitrary string→string metadata (e.g. `title`, `category`, `created_at`). pub metadata: HashMap, } /// `POST /embeddings` body. #[derive(Debug, Deserialize, ToSchema)] pub struct EmbeddingRequest { /// Entity ID the embedding belongs to. #[schema(example = 1)] pub entity_id: u64, /// Dense embedding vector. The database retrieves and ranks over these /// vectors; it does not generate them. pub values: Vec, } /// `POST /signals` body. /// /// `user_id` / `creator_id` are optional signal context. They are read on the /// standalone path; the cluster path ignores them (its replication layer does /// not yet carry per-signal context), so defaulting them keeps both modes' /// wire contract identical. #[derive(Debug, Deserialize, ToSchema)] pub struct SignalRequest { /// Entity the signal is recorded against. #[schema(example = 1)] pub entity_id: u64, /// Signal type name (e.g. `view`, `like`, `share`). #[schema(example = "view")] pub signal: String, /// Signal weight applied to the running decay score. #[schema(example = 1.0)] pub weight: f64, /// Optional originating user context (standalone path only). #[serde(default)] pub user_id: Option, /// Optional originating creator context (standalone path only). #[serde(default)] pub creator_id: Option, } /// Maximum page size a client may request via `?limit=`. /// /// `limit` arrives across the network trust boundary and feeds the engine's /// candidate cap directly. Left unbounded, a single `?limit=4000000000` request /// would drive the retrieval/scoring pipeline to size buffers for a four-billion /// row result, a trivial memory-amplification `DoS`. The page-ranking surfaces /// (`/feed`, `/search`) are interactive UIs — a few hundred ranked items is the /// realistic ceiling — so 1000 is a generous-but-finite cap. Bulk export uses a /// separate, much larger ceiling (`ExportRequest::MAX_EXPORT_LIMIT`); this is /// deliberately the smaller interactive-read limit. Callers clamp with /// [`FeedQuery::clamped_limit`] / [`SearchQueryParams::clamped_limit`] at the /// boundary rather than trusting the raw value. pub const MAX_LIMIT: u32 = 1000; /// `GET /feed` query parameters. #[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] #[into_params(parameter_in = Query)] pub struct FeedQuery { /// Optional user to personalize ranking for. #[serde(default)] pub user_id: Option, /// Named ranking profile; defaults to `for_you`. #[serde(default = "default_profile")] #[param(example = "for_you")] pub profile: String, /// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary. #[serde(default = "default_limit")] #[param(example = 20)] pub limit: u32, /// Target region (cluster mode only; rejected with 400 standalone). #[serde(default)] pub region: Option, } impl FeedQuery { /// The requested page size clamped to [`MAX_LIMIT`]. /// /// Always call this instead of reading `limit` directly: it is the single /// enforcement point for the network trust boundary, so an oversized client /// value can never reach the engine's candidate cap. #[must_use] pub const fn clamped_limit(&self) -> u32 { if self.limit > MAX_LIMIT { MAX_LIMIT } else { self.limit } } } /// `GET /search` query parameters. #[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] #[into_params(parameter_in = Query)] pub struct SearchQueryParams { /// Free-text query string (BM25 + ANN hybrid). #[param(example = "jazz piano")] pub query: String, /// Optional user to personalize ranking for. #[serde(default)] pub user_id: Option, /// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary. #[serde(default = "default_limit")] #[param(example = 20)] pub limit: u32, /// Target region (cluster mode only; rejected with 400 standalone). #[serde(default)] pub region: Option, } impl SearchQueryParams { /// The requested page size clamped to [`MAX_LIMIT`]. /// /// Always call this instead of reading `limit` directly: it is the single /// enforcement point for the network trust boundary, so an oversized client /// value can never reach the engine's candidate cap. #[must_use] pub const fn clamped_limit(&self) -> u32 { if self.limit > MAX_LIMIT { MAX_LIMIT } else { self.limit } } } /// Default ranking profile when `?profile=` is omitted. #[must_use] pub fn default_profile() -> String { "for_you".into() } /// Default page size when `?limit=` is omitted. #[must_use] pub const fn default_limit() -> u32 { 20 } // ── Response DTOs ──────────────────────────────────────────────────────────── /// `GET /feed` response body. #[derive(Debug, Serialize, ToSchema)] pub struct FeedResponse { /// Ranked items, best-first. pub items: Vec, /// Total candidates considered before ranking/diversity. pub total_candidates: usize, /// Region the feed was served from (cluster mode); `null` standalone. pub region: Option, } /// One ranked item in a feed response. #[derive(Debug, Serialize, ToSchema)] pub struct FeedItem { /// Entity ID of the ranked item. pub entity_id: u64, /// Final ranking score. pub score: f64, /// 1-based rank within the page. pub rank: usize, /// Signal snapshot for explainability; omitted when empty. #[serde(skip_serializing_if = "Option::is_none")] pub signals: Option>, } /// A signal value snapshot attached to a ranked item, for explainability. #[derive(Debug, Serialize, ToSchema)] pub struct SignalValue { /// Signal type name. pub name: String, /// Decayed signal value at query time. pub value: f64, } /// `GET /search` response body. #[derive(Debug, Serialize, ToSchema)] pub struct SearchResponse { /// Ranked search results, best-first. pub items: Vec, /// Total candidates considered before ranking/diversity. pub total_candidates: usize, /// Region the search was served from (cluster mode); `null` standalone. pub region: Option, } /// One ranked item in a search response. #[derive(Debug, Serialize, ToSchema)] pub struct SearchItem { /// Entity ID of the ranked item. pub entity_id: u64, /// Final fused ranking score. pub score: f64, /// 1-based rank within the page. pub rank: usize, /// BM25 lexical component; omitted when the query had no text match path. #[serde(skip_serializing_if = "Option::is_none")] pub bm25_score: Option, /// Semantic (ANN) component; omitted when no vector match path ran. #[serde(skip_serializing_if = "Option::is_none")] pub semantic_score: Option, } // ── Engine-result → DTO mapping ────────────────────────────────────────────── /// Map one engine [`RetrieveResult`] into a wire [`FeedItem`]. /// /// An empty signal list serializes as absent (`None`) rather than `[]` so the /// response stays compact. #[must_use] pub fn feed_item(item: &RetrieveResult) -> FeedItem { let signals = if item.signals.is_empty() { None } else { Some( item.signals .iter() .map(|s| SignalValue { name: s.name.clone(), value: s.value, }) .collect(), ) }; FeedItem { entity_id: item.entity_id.as_u64(), score: item.score, rank: item.rank, signals, } } /// Map a slice of engine [`RetrieveResult`]s into wire [`FeedItem`]s. #[must_use] pub fn feed_items(items: &[RetrieveResult]) -> Vec { items.iter().map(feed_item).collect() } /// Map one engine [`SearchResultItem`] into a wire [`SearchItem`]. #[must_use] pub fn search_item(item: &SearchResultItem) -> SearchItem { SearchItem { entity_id: item.entity_id.as_u64(), score: item.score, rank: item.rank, bm25_score: item.bm25_score.map(f64::from), semantic_score: item.semantic_score.map(f64::from), } } /// Map a slice of engine [`SearchResultItem`]s into wire [`SearchItem`]s. #[must_use] pub fn search_items(items: &[SearchResultItem]) -> Vec { items.iter().map(search_item).collect() } #[cfg(test)] mod tests { use super::*; fn feed_query(limit: u32) -> FeedQuery { FeedQuery { user_id: None, profile: default_profile(), limit, region: None, } } fn search_query(limit: u32) -> SearchQueryParams { SearchQueryParams { query: "q".into(), user_id: None, limit, region: None, } } #[test] fn feed_limit_clamped_at_boundary() { // An oversized client-supplied limit is capped, never passed through. assert_eq!(feed_query(u32::MAX).clamped_limit(), MAX_LIMIT); assert_eq!(feed_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT); // Values at or below the cap pass through unchanged. assert_eq!(feed_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT); assert_eq!(feed_query(default_limit()).clamped_limit(), default_limit()); assert_eq!(feed_query(0).clamped_limit(), 0); } #[test] fn search_limit_clamped_at_boundary() { assert_eq!(search_query(u32::MAX).clamped_limit(), MAX_LIMIT); assert_eq!(search_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT); assert_eq!(search_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT); assert_eq!( search_query(default_limit()).clamped_limit(), default_limit() ); } }