- 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
226 lines
8.6 KiB
Rust
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"));
|
|
}
|
|
}
|