use utoipa::openapi::content::Content;
use utoipa::openapi::path::Operation;
use utoipa::openapi::response::{Response, ResponseBuilder};
use utoipa::openapi::security::SecurityRequirement;
use utoipa::openapi::{Ref, RefOr};
use crate::headers::{apply_headers_to_operation, HeaderParam};
#[derive(Clone, Debug, Default)]
pub struct LayerContribution {
pub(crate) headers: Vec<HeaderParam>,
pub(crate) responses: Vec<ResponseContribution>,
pub(crate) security: Vec<SecurityContribution>,
pub(crate) tags: Vec<String>,
pub(crate) badges: Vec<BadgeContribution>,
}
#[derive(Clone, Debug)]
pub struct BadgeContribution {
pub name: String,
pub color: String,
}
impl BadgeContribution {
pub fn new(name: impl Into<String>, color: impl Into<String>) -> Self {
Self {
name: name.into(),
color: color.into(),
}
}
}
#[derive(Clone, Debug)]
pub struct ResponseContribution {
pub status: String,
pub description: String,
pub schema_ref: Option<String>,
}
impl ResponseContribution {
pub fn new(status: impl Into<String>, description: impl Into<String>) -> Self {
Self {
status: status.into(),
description: description.into(),
schema_ref: None,
}
}
pub fn unauthorized() -> Self {
Self::new("401", "Authentication required")
}
pub fn forbidden() -> Self {
Self::new("403", "Permission denied")
}
pub fn with_schema_ref(mut self, ref_path: impl Into<String>) -> Self {
self.schema_ref = Some(ref_path.into());
self
}
pub(crate) fn to_response(&self) -> Response {
let mut b = ResponseBuilder::new().description(self.description.clone());
if let Some(ref_path) = &self.schema_ref {
b = b.content(
"application/json",
Content::new(Some(RefOr::Ref(Ref::new(ref_path.clone())))),
);
}
b.build()
}
}
#[derive(Clone, Debug)]
pub struct SecurityContribution {
pub scheme: String,
pub scopes: Vec<String>,
}
impl SecurityContribution {
pub fn new(scheme: impl Into<String>) -> Self {
Self {
scheme: scheme.into(),
scopes: Vec::new(),
}
}
pub fn with_scopes(mut self, scopes: impl IntoIterator<Item = String>) -> Self {
self.scopes = scopes.into_iter().collect();
self
}
}
impl LayerContribution {
pub fn new() -> Self {
Self::default()
}
pub fn with_header(mut self, h: HeaderParam) -> Self {
self.headers.push(h);
self
}
pub fn with_headers(mut self, hs: impl IntoIterator<Item = HeaderParam>) -> Self {
self.headers.extend(hs);
self
}
pub fn with_response(mut self, r: ResponseContribution) -> Self {
self.responses.push(r);
self
}
pub fn with_security(mut self, s: SecurityContribution) -> Self {
self.security.push(s);
self
}
pub fn with_tag(mut self, t: impl Into<String>) -> Self {
self.tags.push(t.into());
self
}
pub fn with_badge(mut self, b: BadgeContribution) -> Self {
self.badges.push(b);
self
}
pub fn is_empty(&self) -> bool {
self.headers.is_empty()
&& self.responses.is_empty()
&& self.security.is_empty()
&& self.tags.is_empty()
&& self.badges.is_empty()
}
pub fn merge(&mut self, other: LayerContribution) {
self.headers.extend(other.headers);
self.responses.extend(other.responses);
self.security.extend(other.security);
self.tags.extend(other.tags);
self.badges.extend(other.badges);
}
}
pub trait DocumentedLayer {
fn contribution(&self) -> LayerContribution;
}
pub(crate) fn apply_contribution_to_operation(op: &mut Operation, c: &LayerContribution) {
apply_headers_to_operation(op, &c.headers);
for r in &c.responses {
if op.responses.responses.contains_key(&r.status) {
continue;
}
op.responses
.responses
.insert(r.status.clone(), RefOr::T(r.to_response()));
}
if !c.security.is_empty() {
let security = op.security.get_or_insert_with(Vec::new);
for s in &c.security {
merge_security_requirement(security, &s.scheme, &s.scopes);
}
}
if !c.tags.is_empty() {
let tags = op.tags.get_or_insert_with(Vec::new);
for t in &c.tags {
if !tags.iter().any(|existing| existing == t) {
tags.push(t.clone());
}
}
}
for b in &c.badges {
apply_badge_to_operation(op, &b.name, &b.color);
}
}
fn merge_security_requirement(
security: &mut Vec<SecurityRequirement>,
scheme: &str,
scopes: &[String],
) {
if let Some(pos) = security.iter().position(|req| {
serde_json::to_value(req)
.ok()
.and_then(|v| v.as_object().cloned())
.is_some_and(|map| map.contains_key(scheme))
}) {
if !scopes.is_empty() {
let existing = &security[pos];
let mut map: std::collections::BTreeMap<String, Vec<String>> =
serde_json::from_value(serde_json::to_value(existing).unwrap_or_default())
.unwrap_or_default();
if let Some(existing_scopes) = map.get_mut(scheme) {
for scope in scopes {
if !existing_scopes.contains(scope) {
existing_scopes.push(scope.clone());
}
}
}
let merged_scopes = map.get(scheme).cloned().unwrap_or_default();
security[pos] = SecurityRequirement::new(scheme.to_string(), merged_scopes);
}
} else {
security.push(SecurityRequirement::new(
scheme.to_string(),
scopes.to_vec(),
));
}
}
pub fn record_required_permission(op: &mut Operation, scheme: &str, scope: &str, display: &str) {
use utoipa::openapi::extensions::ExtensionsBuilder;
let security = op.security.get_or_insert_with(Vec::new);
merge_security_requirement(security, scheme, &[scope.to_string()]);
let existing_ext = op
.extensions
.as_ref()
.and_then(|ext| serde_json::to_value(ext).ok());
let mut perms = extract_extension_array(existing_ext.as_ref(), "x-required-permissions");
let perm_entry = serde_json::Value::String(display.to_string());
if !perms.contains(&perm_entry) {
perms.push(perm_entry);
}
let mut badges = extract_extension_array(existing_ext.as_ref(), "x-badges");
let badge_entry = serde_json::json!({
"name": display,
"color": "var(--scalar-color-accent)",
});
let already_badged = badges
.iter()
.any(|b| b.get("name") == badge_entry.get("name"));
if !already_badged {
badges.push(badge_entry);
}
let ext = ExtensionsBuilder::new()
.add("x-required-permissions", serde_json::Value::Array(perms))
.add("x-badges", serde_json::Value::Array(badges))
.build();
match op.extensions.as_mut() {
Some(existing) => existing.merge(ext),
None => op.extensions = Some(ext),
}
}
pub fn apply_badge_to_operation(op: &mut Operation, name: &str, color: &str) {
use utoipa::openapi::extensions::ExtensionsBuilder;
let existing_ext = op
.extensions
.as_ref()
.and_then(|ext| serde_json::to_value(ext).ok());
let mut badges = extract_extension_array(existing_ext.as_ref(), "x-badges");
let entry = serde_json::json!({ "name": name, "color": color });
let already = badges.iter().any(|b| b.get("name") == entry.get("name"));
if !already {
badges.push(entry);
}
let ext = ExtensionsBuilder::new()
.add("x-badges", serde_json::Value::Array(badges))
.build();
match op.extensions.as_mut() {
Some(existing) => existing.merge(ext),
None => op.extensions = Some(ext),
}
}
fn extract_extension_array(
serialized: Option<&serde_json::Value>,
key: &str,
) -> Vec<serde_json::Value> {
serialized
.and_then(|v| match v {
serde_json::Value::Object(map) => map.get(key).and_then(|v| v.as_array().cloned()),
_ => None,
})
.unwrap_or_default()
}
pub fn apply_contribution(openapi: &mut utoipa::openapi::OpenApi, c: &LayerContribution) {
if c.is_empty() {
return;
}
for path_item in openapi.paths.paths.values_mut() {
for op in path_item_operations_mut(path_item) {
apply_contribution_to_operation(op, c);
}
}
}
pub(crate) fn path_item_operations(
path_item: &utoipa::openapi::path::PathItem,
) -> impl Iterator<Item = &Operation> {
[
path_item.get.as_ref(),
path_item.put.as_ref(),
path_item.post.as_ref(),
path_item.delete.as_ref(),
path_item.options.as_ref(),
path_item.head.as_ref(),
path_item.patch.as_ref(),
path_item.trace.as_ref(),
]
.into_iter()
.flatten()
}
pub(crate) fn path_item_operations_mut(
path_item: &mut utoipa::openapi::path::PathItem,
) -> impl Iterator<Item = &mut Operation> {
[
path_item.get.as_mut(),
path_item.put.as_mut(),
path_item.post.as_mut(),
path_item.delete.as_mut(),
path_item.options.as_mut(),
path_item.head.as_mut(),
path_item.patch.as_mut(),
path_item.trace.as_mut(),
]
.into_iter()
.flatten()
}
#[cfg(test)]
mod tests {
use super::*;
use utoipa::openapi::path::OperationBuilder;
use utoipa::openapi::response::Responses;
fn empty_op() -> Operation {
let mut op = OperationBuilder::new().build();
op.responses = Responses::new();
op
}
#[test]
fn apply_contribution_adds_headers_responses_security_tags_to_operation() {
let mut op = empty_op();
let c = LayerContribution::new()
.with_header(HeaderParam::required("Authorization"))
.with_response(ResponseContribution::unauthorized())
.with_security(SecurityContribution::new("bearer"))
.with_tag("auth");
apply_contribution_to_operation(&mut op, &c);
let params = op.parameters.expect("parameters set");
assert!(params.iter().any(|p| p.name == "Authorization"));
assert!(op.responses.responses.contains_key("401"));
let security = op.security.expect("security set");
assert_eq!(security.len(), 1);
let tags = op.tags.expect("tags set");
assert_eq!(tags, vec!["auth".to_string()]);
}
#[test]
fn apply_contribution_skips_response_status_already_declared_by_handler() {
let mut op = empty_op();
op.responses.responses.insert(
"401".to_string(),
RefOr::T(Response::new("handler-declared 401")),
);
let c = LayerContribution::new().with_response(ResponseContribution::unauthorized());
apply_contribution_to_operation(&mut op, &c);
let resp = op
.responses
.responses
.get("401")
.expect("401 still present");
match resp {
RefOr::T(r) => assert_eq!(r.description, "handler-declared 401"),
RefOr::Ref(_) => panic!("expected inline response"),
}
}
#[test]
fn apply_contribution_dedupes_security_requirement_when_called_twice() {
let mut op = empty_op();
let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
apply_contribution_to_operation(&mut op, &c);
apply_contribution_to_operation(&mut op, &c);
let security = op.security.expect("security set");
assert_eq!(security.len(), 1, "duplicate security requirement");
}
#[test]
fn apply_contribution_dedupes_tag() {
let mut op = empty_op();
let c = LayerContribution::new().with_tag("auth");
apply_contribution_to_operation(&mut op, &c);
apply_contribution_to_operation(&mut op, &c);
let tags = op.tags.expect("tags set");
assert_eq!(tags, vec!["auth".to_string()]);
}
#[test]
fn merge_contribution_concatenates_each_kind_in_order() {
let mut a = LayerContribution::new()
.with_header(HeaderParam::required("X-A"))
.with_tag("a");
let b = LayerContribution::new()
.with_header(HeaderParam::required("X-B"))
.with_tag("b");
a.merge(b);
assert_eq!(a.headers.len(), 2);
assert_eq!(a.headers[0].name, "X-A");
assert_eq!(a.headers[1].name, "X-B");
assert_eq!(a.tags, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn default_contribution_is_empty_no_op() {
let c = LayerContribution::default();
assert!(c.is_empty());
let mut openapi = utoipa::openapi::OpenApiBuilder::new().build();
apply_contribution(&mut openapi, &c);
assert!(openapi.paths.paths.is_empty());
}
#[test]
fn response_contribution_with_schema_ref_emits_json_content() {
let r = ResponseContribution::unauthorized()
.with_schema_ref("#/components/schemas/ApiErrorBody");
let resp = r.to_response();
let content = resp
.content
.get("application/json")
.expect("application/json content present");
match &content.schema {
Some(RefOr::Ref(_)) => {}
_ => panic!("expected $ref schema"),
}
}
#[test]
fn scoped_extractor_then_bare_layer_produces_single_entry() {
let mut op = empty_op();
record_required_permission(&mut op, "bearer", "widgets.read", "Read widgets");
let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
apply_contribution_to_operation(&mut op, &c);
let security = op.security.expect("security set");
assert_eq!(
security.len(),
1,
"bare layer entry should merge into scoped extractor entry"
);
let json = serde_json::to_value(&security[0]).unwrap();
let scopes = json.get("bearer").unwrap().as_array().unwrap();
assert_eq!(scopes, &[serde_json::json!("widgets.read")]);
}
#[test]
fn bare_layer_then_scoped_extractor_produces_single_entry() {
let mut op = empty_op();
let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
apply_contribution_to_operation(&mut op, &c);
record_required_permission(&mut op, "bearer", "widgets.write", "Write widgets");
let security = op.security.expect("security set");
assert_eq!(
security.len(),
1,
"scoped extractor entry should merge into bare layer entry"
);
let json = serde_json::to_value(&security[0]).unwrap();
let scopes = json.get("bearer").unwrap().as_array().unwrap();
assert_eq!(scopes, &[serde_json::json!("widgets.write")]);
}
#[test]
fn multiple_scopes_merge_into_single_entry() {
let mut op = empty_op();
record_required_permission(&mut op, "bearer", "widgets.read", "Read");
record_required_permission(&mut op, "bearer", "widgets.write", "Write");
let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
apply_contribution_to_operation(&mut op, &c);
let security = op.security.expect("security set");
assert_eq!(security.len(), 1);
let json = serde_json::to_value(&security[0]).unwrap();
let scopes = json.get("bearer").unwrap().as_array().unwrap();
assert!(scopes.contains(&serde_json::json!("widgets.read")));
assert!(scopes.contains(&serde_json::json!("widgets.write")));
}
}