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
239 }
240
241 /// Enables response security headers.
242 pub fn security_headers(mut self) -> Self {
243 self.security_headers = true;
244 self
245 }
246
247 /// Disables response security headers.
248 pub fn without_security_headers(mut self) -> Self {
249 self.security_headers = false;
250 self
251 }
252
253 /// Sets a default request timeout.
254 ///
255 /// Requests whose inner service does not finish before this duration receive
256 /// `408 Request Timeout` with a plain-text `request timed out` body.
257 pub fn timeout(mut self, timeout: Duration) -> Self {
258 self.timeout = Some(timeout);
259 self
260 }
261
262 /// Disables timeout middleware.
263 pub fn without_timeout(mut self) -> Self {
264 self.timeout = None;
265 self
266 }
267
268 /// Disables the panic-catching layer.
269 ///
270 /// With it disabled, a panicking handler may abort the connection instead of
271 /// yielding the production `500` envelope. It is enabled by
272 /// [`Self::production`].
273 pub fn without_catch_panic(mut self) -> Self {
274 self.catch_panic = false;
275 self
276 }
277
278 /// Enables the default Nidus unmatched-route fallback.
279 ///
280 /// The fallback returns a `404` [`crate::error::HttpError`] with code
281 /// `not_found`, allowing the production error-envelope layer to attach
282 /// request ID, path, timestamp, and JSON content type consistently.
283 pub fn not_found_fallback(mut self) -> Self {
284 self.not_found_fallback = true;
285 self
286 }
287
288 /// Disables the default unmatched-route fallback.
289 ///
290 /// Use this when an application installs its own Axum fallback before
291 /// calling [`Self::apply`].
292 pub fn without_not_found_fallback(mut self) -> Self {
293 self.not_found_fallback = false;
294 self
295 }
296
297 /// Applies the configured defaults to an existing router.
298 ///
299 /// Health routes are merged first. The effective inbound request order for
300 /// the default production stack is (outermost first):
301 ///
302 /// 1. [`security_headers_layer`] response wrapper
303 /// 2. [`validated_request_id_layer`]
304 /// 3. [`request_context_layer`]
305 /// 4. metrics, when configured
306 /// 5. [`ErrorEnvelopeLayer`]
307 /// 6. [`timeout_response_layer`]
308 /// 7. [`body_limit_layer`] `Content-Length` boundary
309 /// 8. rate limiting, when configured
310 /// 9. [`catch_panic_layer`], when enabled (innermost, a handler panic is
311 /// caught and surfaced as a `500` through every outer layer)
312 /// 10. route handlers
313 ///
314 /// `body_limit` sits inside the request-id, metrics, and error-envelope
315 /// layers so an oversized-body `413` is enveloped, metered, and carries a
316 /// request id (consistent with how `408` timeouts are observed), rather than
317 /// being rejected invisibly at the edge.
318 ///
319 /// Order matters when adding route-specific layers. Layers installed on a
320 /// route before calling `apply` run inside these defaults, so they can see
321 /// the validated request ID and enriched [`crate::context::RequestContext`],
322 /// and their error responses can be wrapped by the production envelope.
323 pub fn apply(self, mut router: Router) -> Router {
324 if let Some(health) = self.health {
325 router = router.merge(health.routes());
326 }
327 if self.not_found_fallback {
328 router = router.fallback(not_found_fallback);
329 }
330 // Innermost layer: catch handler panics so they surface as an enveloped
331 // 500 through every outer layer instead of aborting the connection.
332 if self.catch_panic {
333 router = router.layer(catch_panic_layer());
334 }
335 if let Some(rate_limit) = self.rate_limit {
336 router = router.layer(rate_limit.layer());
337 }
338 if let Some(max_bytes) = self.body_limit {
339 router = router.layer(body_limit_layer(max_bytes));
340 }
341 if let Some(max_bytes) = self.streaming_body_limit {
342 router = router.layer(streaming_body_limit_layer(max_bytes));
343 }
344 if let Some(timeout) = self.timeout {
345 router = router.layer(timeout_response_layer(timeout));
346 }
347 if self.error_envelope {
348 router = router.layer(ErrorEnvelopeLayer::new());
349 }
350 if let Some(metrics) = self.metrics {
351 router = router.layer(metrics.layer());
352 }
353 if self.request_context {
354 router = router.layer(request_context_layer());
355 }
356 if let Some(request_ids) = self.request_ids {
357 router = router.layer(validated_request_id_layer(request_ids));
358 }
359 if self.security_headers {
360 router = router.layer(security_headers_layer());
361 }
362 router
363 }
364}