- Add k8s/ manifests (StatefulSet, kustomize, PDB, ServiceMonitor) + docs/runbooks/kubernetes.md - Add tidal-server/src/openapi.rs (utoipa OpenAPI spec) and wire into router - Add docs/guides/ (build-a-feed-app, embeddings, server-deployment) + foryou_feed example - Consolidate tidal/docker/ into root docker/ (single canonical home) - Update API.md, QUICKSTART.md, README.md, CLAUDE.md, check-docs.sh accordingly
323 lines
11 KiB
Rust
323 lines
11 KiB
Rust
//! 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<String, String>,
|
|
}
|
|
|
|
/// `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<f32>,
|
|
}
|
|
|
|
/// `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<u64>,
|
|
/// Optional originating creator context (standalone path only).
|
|
#[serde(default)]
|
|
pub creator_id: Option<u64>,
|
|
}
|
|
|
|
/// 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<u64>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
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<u64>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
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<FeedItem>,
|
|
/// Total candidates considered before ranking/diversity.
|
|
pub total_candidates: usize,
|
|
/// Region the feed was served from (cluster mode); `null` standalone.
|
|
pub region: Option<String>,
|
|
}
|
|
|
|
/// 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<Vec<SignalValue>>,
|
|
}
|
|
|
|
/// 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<SearchItem>,
|
|
/// Total candidates considered before ranking/diversity.
|
|
pub total_candidates: usize,
|
|
/// Region the search was served from (cluster mode); `null` standalone.
|
|
pub region: Option<String>,
|
|
}
|
|
|
|
/// 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<f64>,
|
|
/// Semantic (ANN) component; omitted when no vector match path ran.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub semantic_score: Option<f64>,
|
|
}
|
|
|
|
// ── 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<FeedItem> {
|
|
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<SearchItem> {
|
|
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()
|
|
);
|
|
}
|
|
}
|