Skip to main content

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}