1use utoipa::openapi::path::{Operation, Parameter, ParameterBuilder, ParameterIn};
17use utoipa::openapi::{Object, RefOr, Required, Schema, Type};
18
19pub trait DocumentedHeader {
43 fn name() -> &'static str;
48
49 fn description() -> &'static str {
52 ""
53 }
54
55 fn example() -> Option<&'static str> {
57 None
58 }
59}
60
61#[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 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 pub fn typed_optional<H: DocumentedHeader>() -> Self {
89 Self {
90 required: false,
91 ..Self::typed::<H>()
92 }
93 }
94
95 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 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 pub fn description(mut self, d: impl Into<String>) -> Self {
118 self.description = Some(d.into());
119 self
120 }
121
122 pub fn example(mut self, e: impl Into<String>) -> Self {
124 self.example = Some(e.into());
125 self
126 }
127
128 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
151pub(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 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}