Skip to main content

doxa_docs/
headers.rs

1//! Type-safe declaration of request headers, modeled after
2//! [`axum_extra::TypedHeader`](https://docs.rs/axum-extra/latest/axum_extra/typed_header/struct.TypedHeader.html).
3//!
4//! A marker struct implements [`DocumentedHeader`] and exposes the
5//! wire name plus optional metadata via runtime methods (not
6//! associated consts), which keeps the door open for a future blanket
7//! impl over the foreign [`headers::Header`](https://docs.rs/headers)
8//! trait — its `name()` is also a runtime fn, so the two can compose
9//! behind a feature flag without breaking changes here.
10//!
11//! Marker types compose freely across crates with zero runtime
12//! overhead. The same marker can be reused on the layer side via
13//! [`HeaderParam::typed`] and on the handler side via
14//! [`crate::Header`] / the `headers(...)` macro argument.
15
16use utoipa::openapi::path::{Operation, Parameter, ParameterBuilder, ParameterIn};
17use utoipa::openapi::{Object, RefOr, Required, Schema, Type};
18
19/// Type-level descriptor for one HTTP header.
20///
21/// Implementations are typically zero-sized marker structs:
22///
23/// ```rust
24/// use doxa::DocumentedHeader;
25///
26/// pub struct BearerAuthorization;
27/// impl DocumentedHeader for BearerAuthorization {
28///     fn name() -> &'static str { "Authorization" }
29///     fn description() -> &'static str {
30///         "Bearer JWT issued by the configured identity provider."
31///     }
32///     fn example() -> Option<&'static str> {
33///         Some("Bearer eyJhbGc...")
34///     }
35/// }
36/// ```
37///
38/// All accessors are runtime functions (not associated consts) so a
39/// future blanket impl can adapt foreign traits whose names are only
40/// available through methods — e.g. `impl<H: headers::Header>
41/// DocumentedHeader for H` behind an opt-in cargo feature.
42pub trait DocumentedHeader {
43    /// Wire name of the header. HTTP header names are case-insensitive,
44    /// but OpenAPI viewers (Scalar, Swagger UI) render the name
45    /// verbatim — use the title-cased form your consumers expect to
46    /// see (`Authorization`, not `authorization`).
47    fn name() -> &'static str;
48
49    /// Human description rendered in the docs UI. Empty string omits
50    /// the description from the spec.
51    fn description() -> &'static str {
52        ""
53    }
54
55    /// Optional example value rendered alongside the parameter.
56    fn example() -> Option<&'static str> {
57        None
58    }
59}
60
61/// One header parameter descriptor. Construct via
62/// [`HeaderParam::typed`] / [`HeaderParam::typed_optional`] for
63/// type-safe declarations driven by [`DocumentedHeader`], or via
64/// [`HeaderParam::required`] / [`HeaderParam::optional`] for ad-hoc
65/// string names.
66#[derive(Clone, Debug)]
67pub struct HeaderParam {
68    pub(crate) name: String,
69    pub(crate) description: Option<String>,
70    pub(crate) required: bool,
71    pub(crate) example: Option<String>,
72}
73
74impl HeaderParam {
75    /// Build a required header from a [`DocumentedHeader`] marker.
76    /// Resolves the name and metadata at the time of the call.
77    pub fn typed<H: DocumentedHeader>() -> Self {
78        let desc = H::description();
79        Self {
80            name: H::name().to_string(),
81            description: (!desc.is_empty()).then(|| desc.to_string()),
82            required: true,
83            example: H::example().map(str::to_string),
84        }
85    }
86
87    /// Build an optional header from a [`DocumentedHeader`] marker.
88    pub fn typed_optional<H: DocumentedHeader>() -> Self {
89        Self {
90            required: false,
91            ..Self::typed::<H>()
92        }
93    }
94
95    /// Build an ad-hoc required header from a string name. Prefer
96    /// [`HeaderParam::typed`] when a marker type is available.
97    pub fn required(name: impl Into<String>) -> Self {
98        Self {
99            name: name.into(),
100            description: None,
101            required: true,
102            example: None,
103        }
104    }
105
106    /// Build an ad-hoc optional header from a string name.
107    pub fn optional(name: impl Into<String>) -> Self {
108        Self {
109            name: name.into(),
110            description: None,
111            required: false,
112            example: None,
113        }
114    }
115
116    /// Set the description (chainable).
117    pub fn description(mut self, d: impl Into<String>) -> Self {
118        self.description = Some(d.into());
119        self
120    }
121
122    /// Set the example value (chainable).
123    pub fn example(mut self, e: impl Into<String>) -> Self {
124        self.example = Some(e.into());
125        self
126    }
127
128    /// Build the utoipa [`Parameter`] for this header.
129    pub(crate) fn to_parameter(&self) -> Parameter {
130        let mut b = ParameterBuilder::new()
131            .name(&self.name)
132            .parameter_in(ParameterIn::Header)
133            .required(if self.required {
134                Required::True
135            } else {
136                Required::False
137            })
138            .schema(Some(RefOr::T(Schema::Object(Object::with_type(
139                Type::String,
140            )))));
141        if let Some(d) = &self.description {
142            b = b.description(Some(d.clone()));
143        }
144        if let Some(e) = &self.example {
145            b = b.example(Some(serde_json::Value::String(e.clone())));
146        }
147        b.build()
148    }
149}
150
151/// Append `headers` as `in: header` parameters on `op`. Skips any
152/// header whose name (case-insensitive) is already present on the
153/// operation — handler-level declarations always win over
154/// layer-injected defaults.
155pub(crate) fn apply_headers_to_operation(op: &mut Operation, headers: &[HeaderParam]) {
156    if headers.is_empty() {
157        return;
158    }
159    let existing = op.parameters.get_or_insert_with(Vec::new);
160    for h in headers {
161        let dup = existing.iter().any(|p| {
162            matches!(p.parameter_in, ParameterIn::Header) && p.name.eq_ignore_ascii_case(&h.name)
163        });
164        if !dup {
165            existing.push(h.to_parameter());
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use utoipa::openapi::path::OperationBuilder;
174
175    struct XApiKey;
176    impl DocumentedHeader for XApiKey {
177        fn name() -> &'static str {
178            "X-Api-Key"
179        }
180        fn description() -> &'static str {
181            "Tenant API key"
182        }
183        fn example() -> Option<&'static str> {
184            Some("ak_live_42")
185        }
186    }
187
188    struct BareHeader;
189    impl DocumentedHeader for BareHeader {
190        fn name() -> &'static str {
191            "X-Bare"
192        }
193    }
194
195    #[test]
196    fn header_param_typed_calls_runtime_name_fn() {
197        let p = HeaderParam::typed::<XApiKey>();
198        assert_eq!(p.name, "X-Api-Key");
199        assert!(p.required);
200    }
201
202    #[test]
203    fn header_param_typed_picks_up_description_and_example() {
204        let p = HeaderParam::typed::<XApiKey>();
205        assert_eq!(p.description.as_deref(), Some("Tenant API key"));
206        assert_eq!(p.example.as_deref(), Some("ak_live_42"));
207    }
208
209    #[test]
210    fn header_param_typed_omits_description_when_empty() {
211        let p = HeaderParam::typed::<BareHeader>();
212        assert!(p.description.is_none());
213        assert!(p.example.is_none());
214    }
215
216    #[test]
217    fn header_param_typed_optional_serializes_required_false() {
218        let p = HeaderParam::typed_optional::<XApiKey>();
219        assert!(!p.required);
220        let param = p.to_parameter();
221        assert!(matches!(param.required, Required::False));
222    }
223
224    #[test]
225    fn header_param_required_serializes_required_true() {
226        let param = HeaderParam::typed::<XApiKey>().to_parameter();
227        assert!(matches!(param.required, Required::True));
228        assert!(matches!(param.parameter_in, ParameterIn::Header));
229    }
230
231    #[test]
232    fn apply_headers_appends_in_header_parameter() {
233        let mut op = OperationBuilder::new().build();
234        apply_headers_to_operation(&mut op, &[HeaderParam::typed::<XApiKey>()]);
235        let params = op.parameters.expect("parameters set");
236        assert_eq!(params.len(), 1);
237        assert_eq!(params[0].name, "X-Api-Key");
238        assert!(matches!(params[0].parameter_in, ParameterIn::Header));
239    }
240
241    #[test]
242    fn apply_headers_skips_when_handler_already_declares_same_header_case_insensitive() {
243        // Pre-populate the operation with a manually declared header
244        // whose name differs only in case.
245        let manual = ParameterBuilder::new()
246            .name("x-api-key")
247            .parameter_in(ParameterIn::Header)
248            .required(Required::False)
249            .build();
250        let mut op = OperationBuilder::new().build();
251        op.parameters = Some(vec![manual]);
252
253        apply_headers_to_operation(&mut op, &[HeaderParam::typed::<XApiKey>()]);
254
255        let params = op.parameters.expect("parameters set");
256        assert_eq!(params.len(), 1, "manual header should suppress injection");
257        assert_eq!(params[0].name, "x-api-key");
258        assert!(matches!(params[0].required, Required::False));
259    }
260
261    #[test]
262    fn apply_headers_preserves_existing_path_and_query_params() {
263        let path_param = ParameterBuilder::new()
264            .name("id")
265            .parameter_in(ParameterIn::Path)
266            .required(Required::True)
267            .build();
268        let query_param = ParameterBuilder::new()
269            .name("page")
270            .parameter_in(ParameterIn::Query)
271            .required(Required::False)
272            .build();
273        let mut op = OperationBuilder::new().build();
274        op.parameters = Some(vec![path_param, query_param]);
275
276        apply_headers_to_operation(&mut op, &[HeaderParam::typed::<XApiKey>()]);
277
278        let params = op.parameters.expect("parameters set");
279        assert_eq!(params.len(), 3);
280        assert!(params.iter().any(|p| p.name == "id"));
281        assert!(params.iter().any(|p| p.name == "page"));
282        assert!(params.iter().any(|p| p.name == "X-Api-Key"));
283    }
284
285    #[test]
286    fn apply_headers_with_empty_slice_is_noop() {
287        let mut op = OperationBuilder::new().build();
288        apply_headers_to_operation(&mut op, &[]);
289        assert!(op.parameters.is_none());
290    }
291}