datapress_core/handlers/mod.rs
1//! HTTP handler surface, organised by API version.
2//!
3//! ## Layout
4//!
5//! - This module hosts **unversioned** endpoints (liveness / readiness /
6//! `/health`) plus shared utilities used by every version (content
7//! negotiation, the [`BackendData`] extractor type, the Arrow IPC MIME
8//! constant).
9//! - Each API version lives in its own submodule ([`v1`], future
10//! `v2`, …). Versions expose a single [`actix_web::web::ServiceConfig`]
11//! registration function so the server can mount them under a scope:
12//!
13//! ```ignore
14//! App::new()
15//! .service(web::scope("/api/v1").configure(handlers::v1::configure))
16//! ```
17//!
18//! ## Adding a new version
19//!
20//! 1. Copy `v1.rs` to `v2.rs` and adjust the request / response handlers.
21//! 2. Add `pub mod v2;` below.
22//! 3. Mount it in [`crate::server::serve`] under `/api/v2`.
23//! 4. Decide whether `v1` should be kept (it usually is, for a deprecation
24//! window) or removed.
25//!
26//! Handlers inside a version module are plain `async fn` (no route
27//! macros) so the same handler can be re-mounted in multiple scopes —
28//! that's how the legacy un-versioned `/api/datasets/...` alias works
29//! without duplicating code.
30
31use std::sync::Arc;
32
33use actix_web::{HttpRequest, HttpResponse, get, web};
34
35use crate::backend::Backend;
36
37pub mod v1;
38
39/// Convenience alias — every handler extracts the backend through this.
40pub type BackendData = web::Data<Arc<dyn Backend>>;
41
42/// Query-related limits copied from `[server]` config into Actix app data.
43#[derive(Debug, Clone, Copy)]
44pub struct QueryLimits {
45 pub max_page_size: u64,
46}
47
48impl Default for QueryLimits {
49 fn default() -> Self {
50 Self {
51 max_page_size: 100_000,
52 }
53 }
54}
55
56/// MIME type used for Arrow IPC stream responses.
57pub const ARROW_IPC_MIME: &str = "application/vnd.apache.arrow.stream";
58
59#[get("/health")]
60pub async fn health() -> HttpResponse {
61 HttpResponse::Ok()
62 .content_type("application/json")
63 .body(r#"{"status":"ok"}"#)
64}
65
66/// Liveness probe. Mounted outside the configured `prefix` at a fixed
67/// path so orchestrators don't need to know how the server is exposed.
68#[get("/healthz")]
69pub async fn healthz() -> HttpResponse {
70 HttpResponse::Ok()
71 .content_type("application/json")
72 .body(r#"{"status":"ok"}"#)
73}
74
75/// Readiness probe. Returns `200` once at least one dataset is registered
76/// (i.e. the registry finished loading at startup), `503` otherwise.
77#[get("/readyz")]
78pub async fn readyz(backend: BackendData) -> HttpResponse {
79 let names = backend.names();
80 if names.is_empty() {
81 HttpResponse::ServiceUnavailable()
82 .content_type("application/json")
83 .body(r#"{"status":"not ready","reason":"no datasets registered"}"#)
84 } else {
85 let body = format!(r#"{{"status":"ready","datasets":{}}}"#, names.len());
86 HttpResponse::Ok()
87 .content_type("application/json")
88 .body(body)
89 }
90}
91
92/// Build / version metadata published by [`version`] at `/version`.
93///
94/// Populated once by [`crate::server::serve`] from compile-time
95/// constants (`CARGO_PKG_*`) and optional build-time env vars
96/// (`DATAPRESS_GIT_SHA`, `DATAPRESS_BUILD_TIME`), and stored in actix
97/// app data. The handler just serialises it to JSON.
98#[derive(Clone, Debug, serde::Serialize)]
99pub struct BuildInfo {
100 /// Crate name (e.g. `"datapress-core"`).
101 pub name: &'static str,
102 /// Crate version from `CARGO_PKG_VERSION` (e.g. `"0.1.17"`).
103 pub version: &'static str,
104 /// Human-readable backend label — `"DuckDB"` or `"DataFusion"`.
105 pub backend: &'static str,
106 /// Git commit SHA the binary was built from. `None` when
107 /// `DATAPRESS_GIT_SHA` was not set at build time.
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub git_sha: Option<&'static str>,
110 /// ISO-8601 build timestamp. `None` when `DATAPRESS_BUILD_TIME`
111 /// was not set at build time.
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub build_time: Option<&'static str>,
114 /// `"debug"` or `"release"`, derived from `cfg!(debug_assertions)`.
115 pub profile: &'static str,
116 /// Rust target triple the binary was built for (e.g.
117 /// `"aarch64-apple-darwin"`). `None` when `DATAPRESS_TARGET` was
118 /// not set at build time.
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub target: Option<&'static str>,
121}
122
123impl BuildInfo {
124 /// Build a `BuildInfo` populated from compile-time constants. The
125 /// caller supplies the backend `label` (the binaries know which
126 /// they are; this crate doesn't).
127 pub fn new(backend: &'static str) -> Self {
128 Self {
129 name: env!("CARGO_PKG_NAME"),
130 version: env!("CARGO_PKG_VERSION"),
131 backend,
132 git_sha: option_env!("DATAPRESS_GIT_SHA"),
133 build_time: option_env!("DATAPRESS_BUILD_TIME"),
134 profile: if cfg!(debug_assertions) {
135 "debug"
136 } else {
137 "release"
138 },
139 target: option_env!("DATAPRESS_TARGET"),
140 }
141 }
142}
143
144/// Build / version info. Mounted unprefixed so orchestrators and
145/// release-tracking tools can hit it without knowing how the server
146/// is exposed. Always returns `200` with a JSON object.
147#[get("/version")]
148pub async fn version(info: web::Data<BuildInfo>) -> HttpResponse {
149 HttpResponse::Ok().json(info.get_ref())
150}
151
152/// True if the caller wants Arrow IPC: either `?format=arrow` in the
153/// query string, or `Accept` lists `application/vnd.apache.arrow.stream`.
154/// A bare `Accept: */*` does **not** count — JSON stays the default.
155pub(crate) fn wants_arrow(http: &HttpRequest) -> bool {
156 let qs = http.query_string();
157 if !qs.is_empty()
158 && qs.split('&').any(|kv| matches!(kv.split_once('='), Some(("format", v)) if v.eq_ignore_ascii_case("arrow")))
159 {
160 return true;
161 }
162 http.headers()
163 .get(actix_web::http::header::ACCEPT)
164 .and_then(|h| h.to_str().ok())
165 .map(|s| {
166 s.split(',').any(|part| {
167 part.split(';')
168 .next()
169 .unwrap_or("")
170 .trim()
171 .eq_ignore_ascii_case(ARROW_IPC_MIME)
172 })
173 })
174 .unwrap_or(false)
175}