//! 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}; // ── Request DTOs ───────────────────────────────────────────────────────────── /// `POST /items` body. #[derive(Debug, Deserialize)] pub struct ItemRequest { pub entity_id: u64, pub metadata: HashMap, } /// `POST /embeddings` body. #[derive(Debug, Deserialize)] pub struct EmbeddingRequest { pub entity_id: u64, 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)] pub struct SignalRequest { pub entity_id: u64, pub signal: String, pub weight: f64, #[serde(default)] pub user_id: Option, #[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)] pub struct FeedQuery { #[serde(default)] pub user_id: Option, #[serde(default = "default_profile")] pub profile: String, #[serde(default = "default_limit")] pub limit: u32, #[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)] pub struct SearchQueryParams { pub query: String, #[serde(default)] pub user_id: Option, #[serde(default = "default_limit")] pub limit: u32, #[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)] pub struct FeedResponse { pub items: Vec, pub total_candidates: usize, pub region: Option, } /// One ranked item in a feed response. #[derive(Debug, Serialize)] pub struct FeedItem { pub entity_id: u64, pub score: f64, pub rank: usize, #[serde(skip_serializing_if = "Option::is_none")] pub signals: Option>, } /// A signal value snapshot attached to a ranked item, for explainability. #[derive(Debug, Serialize)] pub struct SignalValue { pub name: String, pub value: f64, } /// `GET /search` response body. #[derive(Debug, Serialize)] pub struct SearchResponse { pub items: Vec, pub total_candidates: usize, pub region: Option, } /// One ranked item in a search response. #[derive(Debug, Serialize)] pub struct SearchItem { pub entity_id: u64, pub score: f64, pub rank: usize, #[serde(skip_serializing_if = "Option::is_none")] pub bm25_score: Option, #[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() ); } }