Skip to main content

nidus_http/middleware/
api_defaults.rs

1use std::time::Duration;
2
3use axum::Router;
4
5use crate::{
6    error::{ErrorEnvelopeLayer, not_found_fallback},
7    health::HealthRegistry,
8    middleware::{
9        PrometheusMetrics, RateLimitConfig, RequestIdConfig, body_limit_layer, catch_panic_layer,
10        request_context_layer, security_headers_layer, streaming_body_limit_layer,
11        timeout_response_layer, validated_request_id_layer,
12    },
13};
14
15/// High-level configurable API defaults built from explicit Axum/Tower primitives.
16///
17/// `ApiDefaults` is a convenience builder for production-oriented middleware,
18/// not a hidden application runtime. [`ApiDefaults::production`] starts with
19/// request IDs, request context, production error envelopes, health routes,
20/// security headers, a `Content-Length` body limit, and a timeout enabled.
21/// Prometheus metrics and rate limiting are opt-in and only run when configured.
22///
23/// `version` and `environment` are stored as labels on this builder for callers
24/// that want to keep one deployment metadata object, but [`ApiDefaults::apply`]
25/// does not currently emit those labels to logs, metrics, headers, or health
26/// responses.
27///
28/// ```
29/// use axum::{Router, routing::get};
30/// use nidus_http::{
31///     health::{HealthRegistry, HealthStatus},
32///     middleware::{ApiDefaults, PrometheusMetrics, RequestIdConfig},
33/// };
34/// # async fn list_users() -> &'static str { "users" }
35///
36/// let metrics = PrometheusMetrics::new();
37/// let health = HealthRegistry::new()
38///     .ready_check_sync("database", || HealthStatus::up());
39///
40/// let router = Router::new().route("/users", get(list_users));
41/// let app = ApiDefaults::production("users-api")
42///     .metrics(metrics.clone())
43///     .health(health)
44///     .request_ids(RequestIdConfig::production())
45///     .apply(router)
46///     .merge(metrics.routes());
47/// # let _: Router = app;
48/// ```
49#[derive(Clone)]
50pub struct ApiDefaults {
51    service_name: String,
52    version: Option<String>,
53    environment: Option<String>,
54    request_ids: Option<RequestIdConfig>,
55    request_context: bool,
56    error_envelope: bool,
57    metrics: Option<PrometheusMetrics>,
58    health: Option<HealthRegistry>,
59    rate_limit: Option<RateLimitConfig>,
60    security_headers: bool,
61    body_limit: Option<u64>,
62    streaming_body_limit: Option<usize>,
63    timeout: Option<Duration>,
64    catch_panic: bool,
65    not_found_fallback: bool,
66}
67
68impl ApiDefaults {
69    /// Creates production defaults for a service.
70    ///
71    /// Enabled by default:
72    /// - request IDs: [`RequestIdConfig::production`], which requires inbound
73    ///   IDs to be UUID v4 and generates UUID v4 IDs when absent
74    /// - request context: [`request_context_layer`]
75    /// - error responses: [`ErrorEnvelopeLayer`]
76    /// - health routes: [`HealthRegistry::new`] at `/health/live` and
77    ///   `/health/ready`
78    /// - security headers: [`security_headers_layer`]
79    /// - body limit: [`body_limit_layer`] with `1 MiB`
80    /// - timeout: [`timeout_response_layer`] with `30s`
81    /// - panic catching: [`catch_panic_layer`] so a panicking handler yields a
82    ///   `500` envelope instead of aborting the connection
83    ///
84    /// Metrics and rate limiting are disabled unless [`Self::metrics`] or
85    /// [`Self::rate_limit`] is called. The metrics middleware records requests,
86    /// but `apply` does not merge the `/metrics` route; merge
87    /// [`PrometheusMetrics::routes`] yourself when you want it exposed.
88    pub fn production(service_name: impl Into<String>) -> Self {
89        Self {
90            service_name: service_name.into(),
91            version: None,
92            environment: None,
93            request_ids: Some(RequestIdConfig::production()),
94            request_context: true,
95            error_envelope: true,
96            metrics: None,
97            health: Some(HealthRegistry::new()),
98            rate_limit: None,
99            security_headers: true,
100            body_limit: Some(1024 * 1024),
101            streaming_body_limit: None,
102            timeout: Some(Duration::from_secs(30)),
103            catch_panic: true,
104            not_found_fallback: true,
105        }
106    }
107
108    /// Returns the service name attached to these defaults.
109    ///
110    /// The current [`Self::apply`] implementation keeps this as builder metadata
111    /// only; it is not emitted by any default middleware.
112    pub fn service_name(&self) -> &str {
113        &self.service_name
114    }
115
116    /// Sets a service version label.
117    ///
118    /// This is metadata on the builder. [`Self::apply`] does not currently
119    /// attach the version to metrics, health responses, logs, or response
120    /// headers.
121    pub fn version(mut self, version: impl Into<String>) -> Self {
122        self.version = Some(version.into());
123        self
124    }
125
126    /// Sets an environment label.
127    ///
128    /// This is metadata on the builder. [`Self::apply`] does not currently
129    /// attach the environment to metrics, health responses, logs, or response
130    /// headers.
131    pub fn environment(mut self, environment: impl Into<String>) -> Self {
132        self.environment = Some(environment.into());
133        self
134    }
135
136    /// Replaces request ID behavior.
137    ///
138    /// Pass [`RequestIdConfig::development`] for permissive inbound validation
139    /// during local development, or a custom config when you need a different
140    /// header name or generator.
141    pub fn request_ids(mut self, config: RequestIdConfig) -> Self {
142        self.request_ids = Some(config);
143        self
144    }
145
146    /// Disables request ID middleware.
147    pub fn without_request_ids(mut self) -> Self {
148        self.request_ids = None;
149        self
150    }
151
152    /// Disables request context middleware.
153    pub fn without_request_context(mut self) -> Self {
154        self.request_context = false;
155        self
156    }
157
158    /// Disables production error envelopes.
159    pub fn without_error_envelope(mut self) -> Self {
160        self.error_envelope = false;
161        self
162    }
163
164    /// Adds a Prometheus metrics collector.
165    ///
166    /// This installs request lifecycle recording. It does not expose the
167    /// collector's `/metrics` route; merge [`PrometheusMetrics::routes`] into
168    /// the router when you want scrape output.
169    pub fn metrics(mut self, metrics: PrometheusMetrics) -> Self {
170        self.metrics = Some(metrics);
171        self
172    }
173
174    /// Disables metrics middleware.
175    pub fn without_metrics(mut self) -> Self {
176        self.metrics = None;
177        self
178    }
179
180    /// Replaces health routes.
181    ///
182    /// The registry contributes `/health/live` and `/health/ready` routes before
183    /// middleware layers are applied, so the same default security, timeout, and
184    /// body/header handling applies to health responses too.
185    pub fn health(mut self, health: HealthRegistry) -> Self {
186        self.health = Some(health);
187        self
188    }
189
190    /// Disables health route helpers.
191    pub fn without_health(mut self) -> Self {
192        self.health = None;
193        self
194    }
195
196    /// Adds rate limiting.
197    pub fn rate_limit(mut self, config: RateLimitConfig) -> Self {
198        self.rate_limit = Some(config);
199        self
200    }
201
202    /// Disables rate limiting.
203    pub fn without_rate_limit(mut self) -> Self {
204        self.rate_limit = None;
205        self
206    }
207
208    /// Enables or replaces the request body size limit.
209    ///
210    /// The built-in layer checks the declared `Content-Length` header only. It
211    /// rejects declared oversized bodies with `413 Payload Too Large`; it does
212    /// not count streamed bytes when the header is absent or invalid (e.g.
213    /// chunked-transfer clients). For a hard read-time cap across streaming
214    /// bodies, also enable [`Self::streaming_body_limit`].
215    pub fn body_limit(mut self, max_bytes: u64) -> Self {
216        self.body_limit = Some(max_bytes);
217        self
218    }
219
220    /// Enables a streaming request body limit that counts bytes as they are read.
221    ///
222    /// Unlike [`Self::body_limit`] (which inspects only the declared
223    /// `Content-Length`), this wraps the request body and enforces `max_bytes`
224    /// even when `Content-Length` is absent, closing the chunked-transfer
225    /// bypass. The cap is applied as the downstream extractor or handler reads
226    /// the body, so a request is rejected only once it actually reads past the
227    /// limit. This is opt-in because it wraps every request body; pair it with
228    /// [`Self::body_limit`] for an early `Content-Length` rejection plus a hard
229    /// streaming cap.
230    pub fn streaming_body_limit(mut self, max_bytes: usize) -> Self {
231        self.streaming_body_limit = Some(max_bytes);
232        self
233    }
234
235    /// Disables request body size limiting.
236    pub fn without_body_limit(mut self) -> Self {
237        self.body_limit = None;
238        self.streaming_body_limit = None;
239        self
240    }
241
242    /// Enables response security headers.
243    pub fn security_headers(mut self) -> Self {
244        self.security_headers = true;
245        self
246    }
247
248    /// Disables response security headers.
249    pub fn without_security_headers(mut self) -> Self {
250        self.security_headers = false;
251        self
252    }
253
254    /// Sets a default request timeout.
255    ///
256    /// Requests whose inner service does not finish before this duration receive
257    /// `408 Request Timeout` with a plain-text `request timed out` body.
258    pub fn timeout(mut self, timeout: Duration) -> Self {
259        self.timeout = Some(timeout);
260        self
261    }
262
263    /// Disables timeout middleware.
264    pub fn without_timeout(mut self) -> Self {
265        self.timeout = None;
266        self
267    }
268
269    /// Disables the panic-catching layer.
270    ///
271    /// With it disabled, a panicking handler may abort the connection instead of
272    /// yielding the production `500` envelope. It is enabled by
273    /// [`Self::production`].
274    pub fn without_catch_panic(mut self) -> Self {
275        self.catch_panic = false;
276        self
277    }
278
279    /// Enables the default Nidus unmatched-route fallback.
280    ///
281    /// The fallback returns a `404` [`crate::error::HttpError`] with code
282    /// `not_found`, allowing the production error-envelope layer to attach
283    /// request ID, path, timestamp, and JSON content type consistently.
284    pub fn not_found_fallback(mut self) -> Self {
285        self.not_found_fallback = true;
286        self
287    }
288
289    /// Disables the default unmatched-route fallback.
290    ///
291    /// Use this when an application installs its own Axum fallback before
292    /// calling [`Self::apply`].
293    pub fn without_not_found_fallback(mut self) -> Self {
294        self.not_found_fallback = false;
295        self
296    }
297
298    /// Applies the configured defaults to an existing router.
299    ///
300    /// Health routes are merged first. The effective inbound request order for
301    /// the default production stack is (outermost first):
302    ///
303    /// 1. [`security_headers_layer`] response wrapper
304    /// 2. [`validated_request_id_layer`]
305    /// 3. [`request_context_layer`]
306    /// 4. metrics, when configured
307    /// 5. [`ErrorEnvelopeLayer`]
308    /// 6. [`timeout_response_layer`]
309    /// 7. [`streaming_body_limit_layer`], when configured
310    /// 8. [`body_limit_layer`] `Content-Length` boundary
311    /// 9. rate limiting, when configured
312    /// 10. [`catch_panic_layer`], when enabled (innermost, a handler panic is
313    ///     caught and surfaced as a `500` through every outer layer)
314    /// 11. route handlers
315    ///
316    /// `body_limit` sits inside the request-id, metrics, and error-envelope
317    /// layers so an oversized-body `413` is enveloped, metered, and carries a
318    /// request id (consistent with how `408` timeouts are observed), rather than
319    /// being rejected invisibly at the edge.
320    ///
321    /// Order matters when adding route-specific layers. Layers installed on a
322    /// route before calling `apply` run inside these defaults, so they can see
323    /// the validated request ID and enriched [`crate::context::RequestContext`],
324    /// and their error responses can be wrapped by the production envelope.
325    pub fn apply(self, mut router: Router) -> Router {
326        if let Some(health) = self.health {
327            router = router.merge(health.routes());
328        }
329        if self.not_found_fallback {
330            router = router.fallback(not_found_fallback);
331        }
332        // Innermost layer: catch handler panics so they surface as an enveloped
333        // 500 through every outer layer instead of aborting the connection.
334        if self.catch_panic {
335            router = router.layer(catch_panic_layer());
336        }
337        if let Some(rate_limit) = self.rate_limit {
338            router = router.layer(rate_limit.layer());
339        }
340        if let Some(max_bytes) = self.body_limit {
341            router = router.layer(body_limit_layer(max_bytes));
342        }
343        if let Some(max_bytes) = self.streaming_body_limit {
344            router = router.layer(streaming_body_limit_layer(max_bytes));
345        }
346        if let Some(timeout) = self.timeout {
347            router = router.layer(timeout_response_layer(timeout));
348        }
349        if self.error_envelope {
350            router = router.layer(ErrorEnvelopeLayer::new());
351        }
352        if let Some(metrics) = self.metrics {
353            router = router.layer(metrics.layer());
354        }
355        if self.request_context {
356            router = router.layer(request_context_layer());
357        }
358        if let Some(request_ids) = self.request_ids {
359            router = router.layer(validated_request_id_layer(request_ids));
360        }
361        if self.security_headers {
362            router = router.layer(security_headers_layer());
363        }
364        router
365    }
366}