baukit_http/lib.rs
1//! Shared Axum middleware, HTTP telemetry, and public errors for Baukit services.
2//!
3//! [`finalize`] applies one consistent request lifecycle: standard extractor and
4//! routing errors, request identity, W3C trace extraction and response
5//! propagation, route-template spans, HTTP RED metrics, panic and timeout
6//! envelopes, body and concurrency limits, and explicit CORS.
7//!
8//! # Example
9//!
10//! ```rust
11//! use axum::{Router, routing::get};
12//! use baukit_http::{HttpOptions, RequestId, finalize};
13//!
14//! async fn hello(request_id: RequestId) -> String {
15//! format!("hello from request {}", request_id.as_str())
16//! }
17//!
18//! let options = HttpOptions::default()
19//! .with_allowed_origins(["https://app.example.com"])?
20//! .with_additional_allowed_headers(["accept", "x-webhook-secret"])?
21//! .with_json_rejection_code("invalid_json")?;
22//! let app = finalize(Router::new().route("/hello", get(hello)), options);
23//! # let _: Router = app;
24//! # Ok::<(), baukit_http::HttpOptionsError>(())
25//! ```
26//!
27//! [`classify_http_status`] turns an upstream response into a [`RetryClass`] so
28//! outbound clients share one retry policy.
29//!
30//! # Paginated handler
31//!
32//! Keyset pagination lives in `baukit_core::pagination` so domain crates can
33//! use it without Axum. `PaginationError` converts into [`ApiError`].
34//!
35//! ```rust
36//! use axum::extract::Query;
37//! use baukit_core::pagination::{Page, PageKey, PageParams};
38//! use baukit_http::{ApiError, ResponseEnvelope};
39//! use serde::{Deserialize, Serialize};
40//! use uuid::Uuid;
41//!
42//! #[derive(Deserialize)]
43//! struct ListQuery {
44//! limit: Option<i64>,
45//! cursor: Option<String>,
46//! category: Option<String>,
47//! }
48//!
49//! #[derive(Serialize)]
50//! struct Filters {
51//! category: Option<String>,
52//! }
53//!
54//! #[derive(Clone, Serialize)]
55//! struct Item {
56//! id: Uuid,
57//! name: String,
58//! }
59//!
60//! #[derive(Serialize)]
61//! #[serde(rename_all = "camelCase")]
62//! struct PageMeta {
63//! next_cursor: Option<String>,
64//! }
65//!
66//! async fn list(
67//! Query(query): Query<ListQuery>,
68//! ) -> Result<ResponseEnvelope<Vec<Item>, PageMeta>, ApiError> {
69//! let params = PageParams::new(query.limit, query.cursor)?;
70//! let filters = Filters { category: query.category };
71//! let after = params.decode_cursor(&filters)?;
72//!
73//! // Fetch `params.fetch_limit()?` rows ordered by (name, id), starting
74//! // after `after.page_key::<String>()?` when it is present.
75//! let _ = after;
76//! let rows: Vec<Item> = Vec::new();
77//!
78//! let page = Page::from_rows(rows, ¶ms, &filters, |item| {
79//! PageKey::new(item.name.clone(), item.id)
80//! })?;
81//! Ok(ResponseEnvelope::new(
82//! page.items,
83//! PageMeta { next_cursor: page.next_cursor },
84//! ))
85//! }
86//! # let _ = list;
87//! ```
88//!
89//! This crate records HTTP metrics through the [`metrics`] facade and never
90//! installs a recorder. The recorder owner must configure
91//! `http_request_duration_seconds` with [`DURATION_BUCKETS`].
92
93#![deny(missing_docs)]
94
95mod error;
96mod extract;
97mod idempotency;
98mod locale;
99mod middleware;
100mod options;
101mod precondition;
102pub mod retry;
103mod routing;
104
105pub use baukit_openapi::{ErrorBody, ErrorEnvelope, ResponseEnvelope, Rfc3339DateTime};
106pub use error::ApiError;
107pub use extract::{ApiJson, ApiPath, ApiQuery, Path, Query};
108pub use idempotency::{
109 IDEMPOTENCY_KEY, IDEMPOTENCY_KEY_IN_PROGRESS_CODE, IDEMPOTENCY_KEY_REQUIRED_CODE,
110 IDEMPOTENCY_KEY_REUSED_CODE, INVALID_IDEMPOTENCY_KEY_CODE, IdempotencyError, IdempotencyKey,
111 IdempotencyKeyRule, InvalidIdempotencyKey, InvalidIdempotencyKeyRule,
112 MAX_IDEMPOTENCY_KEY_BYTES,
113};
114pub use locale::{
115 LocaleQueryOverride, MAX_ACCEPT_LANGUAGE_BYTES, MAX_LOCALE_QUERY_BYTES, MAX_SUPPORTED_LOCALES,
116 RequestLocale, RequestLocaleConfig, RequestLocaleConfigError, RequestLocaleRejection,
117};
118pub use middleware::{
119 DURATION_BUCKETS, HTTP_REQUEST_DURATION_SECONDS, HTTP_REQUESTS_IN_FLIGHT, HTTP_REQUESTS_TOTAL,
120 RequestId, X_REQUEST_ID, extract_trace_context, inject_current_trace_context,
121 inject_trace_context, layers,
122};
123pub use options::{HttpOptions, HttpOptionsError, JsonRejectionCodes, ResponseCachePolicy};
124pub use precondition::{
125 InvalidEtagPrefix, InvalidIfMatch, MAX_ETAG_PREFIX_BYTES, PreconditionError, Revision,
126 RevisionEtag, RevisionOutOfRange, ensure_current_revision,
127};
128pub use retry::{
129 RetryClass, RetryHeaderOptions, classify_http_status, classify_http_status_with_options,
130 classify_http_status_with_options_at, classify_transport_error, retry_after_from_headers,
131 retry_after_from_headers_at, retry_after_from_headers_with_options,
132 retry_after_from_headers_with_options_at,
133};
134pub use routing::finalize;
135
136/// The metrics facade used by the HTTP lifecycle middleware.
137///
138/// Applications normally initialize a recorder through `baukit-telemetry`.
139pub use metrics;
140
141#[cfg(test)]
142mod tests;
143
144// Compiles the README's examples so they cannot drift from the API.
145#[doc = include_str!("../README.md")]
146#[cfg(doctest)]
147struct ReadmeDoctests;