tidaldb/tidal-server/src/openapi.rs
jx12n 1092d34c39 feat: kubernetes deployment, OpenAPI spec, guides, and docker consolidation
- 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
2026-06-09 17:06:34 -06:00

226 lines
8.6 KiB
Rust

//! Machine-readable `OpenAPI` 3.1 specification for the tidalDB HTTP API.
//!
//! Two [`OpenApi`](utoipa::OpenApi) documents are derived from the
//! `#[utoipa::path(...)]` attributes on the handlers and the
//! `#[derive(ToSchema)]` on the DTOs:
//!
//! * [`StandaloneApiDoc`] — the data + health surface served by
//! [`crate::router::build_router`].
//! * [`ClusterApiDoc`] — the same surface PLUS the cluster-management and
//! sharded (scatter-gather) routes served by
//! [`crate::cluster::build_cluster_router`].
//!
//! Both are served UNAUTHENTICATED at `GET /openapi.json` (sibling to the
//! `/health/*` probes): the document describes the API contract only — it
//! carries no entity data, signals, or secrets — so gating it behind the same
//! bearer token a client needs the spec to learn how to send would be
//! self-defeating. The `bearerAuth` security scheme is declared via the
//! [`SecurityAddon`] modifier so generated clients know the data routes require
//! a token; the spec endpoint itself is exempt.
use axum::Json;
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
/// `GET /openapi.json` handler for the **standalone** surface.
///
/// Mounted unauthenticated alongside the `/health/*` probes by
/// [`crate::router::build_router`]. Returns the derived `OpenAPI` 3.1 document
/// as JSON (`utoipa::openapi::OpenApi` is `Serialize`).
pub(crate) async fn serve_standalone() -> Json<utoipa::openapi::OpenApi> {
Json(StandaloneApiDoc::openapi())
}
/// `GET /openapi.json` handler for the **cluster** surface — the superset
/// document that also describes the `/cluster/*` and `/sharded/*` routes.
/// Mounted by [`crate::cluster::build_cluster_router`].
pub(crate) async fn serve_cluster() -> Json<utoipa::openapi::OpenApi> {
Json(ClusterApiDoc::openapi())
}
/// Injects the `bearerAuth` HTTP security scheme into the components.
///
/// The per-handler `security(("bearerAuth" = []))` attributes reference this
/// scheme by name; without registering it here the generated document would
/// name an undefined scheme. Health and `/openapi.json` carry no `security`
/// requirement, so they remain open.
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
// `components` is always `Some` once any schema is registered, but guard
// rather than unwrap so an empty-component build degrades gracefully
// (the crate forbids `unwrap`/`expect` in non-test code).
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearerAuth",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
/// `OpenAPI` document for the standalone HTTP surface.
#[derive(OpenApi)]
#[openapi(
info(
title = "tidalDB HTTP API",
version = env!("CARGO_PKG_VERSION"),
description = "Embeddable, single-node-first database for the personalized \
content ranking problem. Write items, embeddings, and signals; \
retrieve ranked feeds and run hybrid search. Data routes require \
a Bearer token when TIDAL_API_KEY is configured; health probes \
and this spec do not.",
),
paths(
crate::router::health,
crate::router::create_item,
crate::router::write_embedding,
crate::router::write_signal,
crate::router::feed,
crate::router::search,
),
components(schemas(
crate::dto::ItemRequest,
crate::dto::EmbeddingRequest,
crate::dto::SignalRequest,
crate::dto::FeedResponse,
crate::dto::FeedItem,
crate::dto::SignalValue,
crate::dto::SearchResponse,
crate::dto::SearchItem,
)),
modifiers(&SecurityAddon),
tags(
(name = "health", description = "Liveness / readiness probes (unauthenticated)."),
(name = "data", description = "Item, embedding, signal writes and ranked reads."),
),
)]
pub struct StandaloneApiDoc;
/// `OpenAPI` document for the cluster HTTP surface.
///
/// Superset of [`StandaloneApiDoc`]: the same data + health routes plus the
/// cluster-management (`/cluster/*`) and sharded scatter-gather
/// (`/sharded/*`) routes. The cluster handlers register the SAME `/items`,
/// `/embeddings`, `/signals`, `/feed`, `/search` paths as the standalone ones;
/// because this is a distinct document the shared paths are described once here
/// from the cluster handlers' attributes (region-aware response text), so there
/// is no path-key collision.
#[derive(OpenApi)]
#[openapi(
info(
title = "tidalDB HTTP API",
version = env!("CARGO_PKG_VERSION"),
description = "Cluster-mode tidalDB HTTP surface: the standalone data + health \
routes plus multi-region cluster management and sharded \
scatter-gather routes. EXPERIMENTAL — all regions run in one \
process (no host/process isolation).",
),
paths(
crate::cluster::cluster_status,
crate::cluster::cluster_promote,
crate::cluster::cluster_partition,
crate::cluster::cluster_heal,
crate::cluster::create_item,
crate::cluster::write_embedding,
crate::cluster::write_signal,
crate::cluster::feed,
crate::cluster::search,
crate::cluster::sharded_create_item,
crate::cluster::sharded_write_embedding,
crate::cluster::sharded_write_signal,
crate::cluster::sharded_feed,
crate::cluster::sharded_search,
),
components(schemas(
crate::dto::ItemRequest,
crate::dto::EmbeddingRequest,
crate::dto::SignalRequest,
crate::dto::FeedResponse,
crate::dto::FeedItem,
crate::dto::SignalValue,
crate::dto::SearchResponse,
crate::dto::SearchItem,
crate::cluster::ClusterStatusResponse,
crate::cluster::RegionStatus,
crate::cluster::RegionRequest,
crate::cluster::ScatterGatherInfo,
crate::cluster::ShardedFeedResponse,
crate::cluster::ShardedSearchResponse,
)),
modifiers(&SecurityAddon),
tags(
(name = "health", description = "Liveness / readiness probes (unauthenticated)."),
(name = "data", description = "Item, embedding, signal writes and ranked reads."),
(name = "cluster", description = "Multi-region cluster management."),
(name = "sharded", description = "Scatter-gather routes fanning across all shards."),
),
)]
pub struct ClusterApiDoc;
#[cfg(test)]
mod tests {
use super::*;
/// The standalone document must enumerate the data + health paths and carry
/// the bearerAuth scheme so generated clients know how to authenticate.
#[test]
fn standalone_doc_lists_core_paths() {
let doc = StandaloneApiDoc::openapi();
let paths = &doc.paths.paths;
for p in [
"/health",
"/items",
"/embeddings",
"/signals",
"/feed",
"/search",
] {
assert!(paths.contains_key(p), "standalone doc missing path {p}");
}
// The cluster-only routes must NOT leak into the standalone document.
assert!(
!paths.contains_key("/cluster/status"),
"standalone doc must not advertise cluster routes"
);
let schemes = &doc
.components
.as_ref()
.expect("components present after schema registration")
.security_schemes;
assert!(
schemes.contains_key("bearerAuth"),
"SecurityAddon must register the bearerAuth scheme"
);
}
/// The cluster document is a superset: data + health + cluster + sharded.
#[test]
fn cluster_doc_lists_cluster_and_sharded_paths() {
let doc = ClusterApiDoc::openapi();
let paths = &doc.paths.paths;
for p in [
"/feed",
"/cluster/status",
"/cluster/promote",
"/cluster/partition",
"/cluster/heal",
"/sharded/items",
"/sharded/feed",
"/sharded/search",
] {
assert!(paths.contains_key(p), "cluster doc missing path {p}");
}
}
/// The served version string is the crate version, exposed for the route's
/// contract test and the verification report.
#[test]
fn doc_version_is_crate_version() {
let doc = StandaloneApiDoc::openapi();
assert_eq!(doc.info.version, env!("CARGO_PKG_VERSION"));
}
}