use utoipa::openapi::path::{Operation, Parameter, ParameterBuilder, ParameterIn};
use utoipa::openapi::{Object, RefOr, Required, Schema, Type};
pub trait DocumentedHeader {
fn name() -> &'static str;
fn description() -> &'static str {
""
}
fn example() -> Option<&'static str> {
None
}
}
#[derive(Clone, Debug)]
pub struct HeaderParam {
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) required: bool,
pub(crate) example: Option<String>,
}
impl HeaderParam {
pub fn typed<H: DocumentedHeader>() -> Self {
let desc = H::description();
Self {
name: H::name().to_string(),
description: (!desc.is_empty()).then(|| desc.to_string()),
required: true,
example: H::example().map(str::to_string),
}
}
pub fn typed_optional<H: DocumentedHeader>() -> Self {
Self {
required: false,
..Self::typed::<H>()
}
}
pub fn required(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
required: true,
example: None,
}
}
pub fn optional(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
required: false,
example: None,
}
}
pub fn description(mut self, d: impl Into<String>) -> Self {
self.description = Some(d.into());
self
}
pub fn example(mut self, e: impl Into<String>) -> Self {
self.example = Some(e.into());
self
}
pub(crate) fn to_parameter(&self) -> Parameter {
let mut b = ParameterBuilder::new()
.name(&self.name)
.parameter_in(ParameterIn::Header)
.required(if self.required {
Required::True
} else {
Required::False
})
.schema(Some(RefOr::T(Schema::Object(Object::with_type(
Type::String,
)))));
if let Some(d) = &self.description {
b = b.description(Some(d.clone()));
}
if let Some(e) = &self.example {
b = b.example(Some(serde_json::Value::String(e.clone())));
}
b.build()
}
}
pub(crate) fn apply_headers_to_operation(op: &mut Operation, headers: &[HeaderParam]) {
if headers.is_empty() {
return;
}
let existing = op.parameters.get_or_insert_with(Vec::new);
for h in headers {
let dup = existing.iter().any(|p| {
matches!(p.parameter_in, ParameterIn::Header) && p.name.eq_ignore_ascii_case(&h.name)
});
if !dup {
existing.push(h.to_parameter());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use utoipa::openapi::path::OperationBuilder;
struct XApiKey;
impl DocumentedHeader for XApiKey {
fn name() -> &'static str {
"X-Api-Key"
}
fn description() -> &'static str {
"Tenant API key"
}
fn example() -> Option<&'static str> {
Some("ak_live_42")
}
}
struct BareHeader;
impl DocumentedHeader for BareHeader {
fn name() -> &'static str {
"X-Bare"
}
}
#[test]
fn header_param_typed_calls_runtime_name_fn() {
let p = HeaderParam::typed::<XApiKey>();
assert_eq!(p.name, "X-Api-Key");
assert!(p.required);
}
#[test]
fn header_param_typed_picks_up_description_and_example() {
let p = HeaderParam::typed::<XApiKey>();
assert_eq!(p.description.as_deref(), Some("Tenant API key"));
assert_eq!(p.example.as_deref(), Some("ak_live_42"));
}
#[test]
fn header_param_typed_omits_description_when_empty() {
let p = HeaderParam::typed::<BareHeader>();
assert!(p.description.is_none());
assert!(p.example.is_none());
}
#[test]
fn header_param_typed_optional_serializes_required_false() {
let p = HeaderParam::typed_optional::<XApiKey>();
assert!(!p.required);
let param = p.to_parameter();
assert!(matches!(param.required, Required::False));
}
#[test]
fn header_param_required_serializes_required_true() {
let param = HeaderParam::typed::<XApiKey>().to_parameter();
assert!(matches!(param.required, Required::True));
assert!(matches!(param.parameter_in, ParameterIn::Header));
}
#[test]
fn apply_headers_appends_in_header_parameter() {
let mut op = OperationBuilder::new().build();
apply_headers_to_operation(&mut op, &[HeaderParam::typed::<XApiKey>()]);
let params = op.parameters.expect("parameters set");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "X-Api-Key");
assert!(matches!(params[0].parameter_in, ParameterIn::Header));
}
#[test]
fn apply_headers_skips_when_handler_already_declares_same_header_case_insensitive() {
let manual = ParameterBuilder::new()
.name("x-api-key")
.parameter_in(ParameterIn::Header)
.required(Required::False)
.build();
let mut op = OperationBuilder::new().build();
op.parameters = Some(vec![manual]);
apply_headers_to_operation(&mut op, &[HeaderParam::typed::<XApiKey>()]);
let params = op.parameters.expect("parameters set");
assert_eq!(params.len(), 1, "manual header should suppress injection");
assert_eq!(params[0].name, "x-api-key");
assert!(matches!(params[0].required, Required::False));
}
#[test]
fn apply_headers_preserves_existing_path_and_query_params() {
let path_param = ParameterBuilder::new()
.name("id")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.build();
let query_param = ParameterBuilder::new()
.name("page")
.parameter_in(ParameterIn::Query)
.required(Required::False)
.build();
let mut op = OperationBuilder::new().build();
op.parameters = Some(vec![path_param, query_param]);
apply_headers_to_operation(&mut op, &[HeaderParam::typed::<XApiKey>()]);
let params = op.parameters.expect("parameters set");
assert_eq!(params.len(), 3);
assert!(params.iter().any(|p| p.name == "id"));
assert!(params.iter().any(|p| p.name == "page"));
assert!(params.iter().any(|p| p.name == "X-Api-Key"));
}
#[test]
fn apply_headers_with_empty_slice_is_noop() {
let mut op = OperationBuilder::new().build();
apply_headers_to_operation(&mut op, &[]);
assert!(op.parameters.is_none());
}
}