1use utoipa::openapi::content::Content;
14use utoipa::openapi::path::Operation;
15use utoipa::openapi::response::{Response, ResponseBuilder};
16use utoipa::openapi::security::SecurityRequirement;
17use utoipa::openapi::{Ref, RefOr};
18
19use crate::headers::{apply_headers_to_operation, HeaderParam};
20
21#[derive(Clone, Debug, Default)]
25pub struct LayerContribution {
26 pub(crate) headers: Vec<HeaderParam>,
27 pub(crate) responses: Vec<ResponseContribution>,
28 pub(crate) security: Vec<SecurityContribution>,
29 pub(crate) tags: Vec<String>,
30 pub(crate) badges: Vec<BadgeContribution>,
31}
32
33#[derive(Clone, Debug)]
37pub struct BadgeContribution {
38 pub name: String,
41 pub color: String,
44}
45
46impl BadgeContribution {
47 pub fn new(name: impl Into<String>, color: impl Into<String>) -> Self {
49 Self {
50 name: name.into(),
51 color: color.into(),
52 }
53 }
54}
55
56#[derive(Clone, Debug)]
61pub struct ResponseContribution {
62 pub status: String,
64 pub description: String,
66 pub schema_ref: Option<String>,
70}
71
72impl ResponseContribution {
73 pub fn new(status: impl Into<String>, description: impl Into<String>) -> Self {
76 Self {
77 status: status.into(),
78 description: description.into(),
79 schema_ref: None,
80 }
81 }
82
83 pub fn unauthorized() -> Self {
86 Self::new("401", "Authentication required")
87 }
88
89 pub fn forbidden() -> Self {
92 Self::new("403", "Permission denied")
93 }
94
95 pub fn with_schema_ref(mut self, ref_path: impl Into<String>) -> Self {
97 self.schema_ref = Some(ref_path.into());
98 self
99 }
100
101 pub(crate) fn to_response(&self) -> Response {
103 let mut b = ResponseBuilder::new().description(self.description.clone());
104 if let Some(ref_path) = &self.schema_ref {
105 b = b.content(
106 "application/json",
107 Content::new(Some(RefOr::Ref(Ref::new(ref_path.clone())))),
108 );
109 }
110 b.build()
111 }
112}
113
114#[derive(Clone, Debug)]
120pub struct SecurityContribution {
121 pub scheme: String,
124 pub scopes: Vec<String>,
126}
127
128impl SecurityContribution {
129 pub fn new(scheme: impl Into<String>) -> Self {
133 Self {
134 scheme: scheme.into(),
135 scopes: Vec::new(),
136 }
137 }
138
139 pub fn with_scopes(mut self, scopes: impl IntoIterator<Item = String>) -> Self {
141 self.scopes = scopes.into_iter().collect();
142 self
143 }
144}
145
146impl LayerContribution {
147 pub fn new() -> Self {
149 Self::default()
150 }
151
152 pub fn with_header(mut self, h: HeaderParam) -> Self {
154 self.headers.push(h);
155 self
156 }
157
158 pub fn with_headers(mut self, hs: impl IntoIterator<Item = HeaderParam>) -> Self {
160 self.headers.extend(hs);
161 self
162 }
163
164 pub fn with_response(mut self, r: ResponseContribution) -> Self {
166 self.responses.push(r);
167 self
168 }
169
170 pub fn with_security(mut self, s: SecurityContribution) -> Self {
172 self.security.push(s);
173 self
174 }
175
176 pub fn with_tag(mut self, t: impl Into<String>) -> Self {
178 self.tags.push(t.into());
179 self
180 }
181
182 pub fn with_badge(mut self, b: BadgeContribution) -> Self {
186 self.badges.push(b);
187 self
188 }
189
190 pub fn is_empty(&self) -> bool {
192 self.headers.is_empty()
193 && self.responses.is_empty()
194 && self.security.is_empty()
195 && self.tags.is_empty()
196 && self.badges.is_empty()
197 }
198
199 pub fn merge(&mut self, other: LayerContribution) {
202 self.headers.extend(other.headers);
203 self.responses.extend(other.responses);
204 self.security.extend(other.security);
205 self.tags.extend(other.tags);
206 self.badges.extend(other.badges);
207 }
208}
209
210pub trait DocumentedLayer {
240 fn contribution(&self) -> LayerContribution;
244}
245
246pub(crate) fn apply_contribution_to_operation(op: &mut Operation, c: &LayerContribution) {
250 apply_headers_to_operation(op, &c.headers);
251
252 for r in &c.responses {
254 if op.responses.responses.contains_key(&r.status) {
255 continue;
256 }
257 op.responses
258 .responses
259 .insert(r.status.clone(), RefOr::T(r.to_response()));
260 }
261
262 if !c.security.is_empty() {
266 let security = op.security.get_or_insert_with(Vec::new);
267 for s in &c.security {
268 merge_security_requirement(security, &s.scheme, &s.scopes);
269 }
270 }
271
272 if !c.tags.is_empty() {
274 let tags = op.tags.get_or_insert_with(Vec::new);
275 for t in &c.tags {
276 if !tags.iter().any(|existing| existing == t) {
277 tags.push(t.clone());
278 }
279 }
280 }
281
282 for b in &c.badges {
284 apply_badge_to_operation(op, &b.name, &b.color);
285 }
286}
287
288fn merge_security_requirement(
295 security: &mut Vec<SecurityRequirement>,
296 scheme: &str,
297 scopes: &[String],
298) {
299 if let Some(pos) = security.iter().position(|req| {
302 serde_json::to_value(req)
303 .ok()
304 .and_then(|v| v.as_object().cloned())
305 .is_some_and(|map| map.contains_key(scheme))
306 }) {
307 if !scopes.is_empty() {
309 let existing = &security[pos];
310 let mut map: std::collections::BTreeMap<String, Vec<String>> =
311 serde_json::from_value(serde_json::to_value(existing).unwrap_or_default())
312 .unwrap_or_default();
313 if let Some(existing_scopes) = map.get_mut(scheme) {
314 for scope in scopes {
315 if !existing_scopes.contains(scope) {
316 existing_scopes.push(scope.clone());
317 }
318 }
319 }
320 let merged_scopes = map.get(scheme).cloned().unwrap_or_default();
322 security[pos] = SecurityRequirement::new(scheme.to_string(), merged_scopes);
323 }
324 } else {
327 security.push(SecurityRequirement::new(
328 scheme.to_string(),
329 scopes.to_vec(),
330 ));
331 }
332}
333
334pub fn record_required_permission(op: &mut Operation, scheme: &str, scope: &str, display: &str) {
353 use utoipa::openapi::extensions::ExtensionsBuilder;
354
355 let security = op.security.get_or_insert_with(Vec::new);
356 merge_security_requirement(security, scheme, &[scope.to_string()]);
357
358 let existing_ext = op
362 .extensions
363 .as_ref()
364 .and_then(|ext| serde_json::to_value(ext).ok());
365
366 let mut perms = extract_extension_array(existing_ext.as_ref(), "x-required-permissions");
367 let perm_entry = serde_json::Value::String(display.to_string());
368 if !perms.contains(&perm_entry) {
369 perms.push(perm_entry);
370 }
371
372 let mut badges = extract_extension_array(existing_ext.as_ref(), "x-badges");
373 let badge_entry = serde_json::json!({
374 "name": display,
375 "color": "var(--scalar-color-accent)",
376 });
377 let already_badged = badges
378 .iter()
379 .any(|b| b.get("name") == badge_entry.get("name"));
380 if !already_badged {
381 badges.push(badge_entry);
382 }
383
384 let ext = ExtensionsBuilder::new()
385 .add("x-required-permissions", serde_json::Value::Array(perms))
386 .add("x-badges", serde_json::Value::Array(badges))
387 .build();
388 match op.extensions.as_mut() {
389 Some(existing) => existing.merge(ext),
390 None => op.extensions = Some(ext),
391 }
392}
393
394pub fn apply_badge_to_operation(op: &mut Operation, name: &str, color: &str) {
408 use utoipa::openapi::extensions::ExtensionsBuilder;
409
410 let existing_ext = op
411 .extensions
412 .as_ref()
413 .and_then(|ext| serde_json::to_value(ext).ok());
414 let mut badges = extract_extension_array(existing_ext.as_ref(), "x-badges");
415 let entry = serde_json::json!({ "name": name, "color": color });
416 let already = badges.iter().any(|b| b.get("name") == entry.get("name"));
417 if !already {
418 badges.push(entry);
419 }
420 let ext = ExtensionsBuilder::new()
421 .add("x-badges", serde_json::Value::Array(badges))
422 .build();
423 match op.extensions.as_mut() {
424 Some(existing) => existing.merge(ext),
425 None => op.extensions = Some(ext),
426 }
427}
428
429fn extract_extension_array(
434 serialized: Option<&serde_json::Value>,
435 key: &str,
436) -> Vec<serde_json::Value> {
437 serialized
438 .and_then(|v| match v {
439 serde_json::Value::Object(map) => map.get(key).and_then(|v| v.as_array().cloned()),
440 _ => None,
441 })
442 .unwrap_or_default()
443}
444
445pub fn apply_contribution(openapi: &mut utoipa::openapi::OpenApi, c: &LayerContribution) {
450 if c.is_empty() {
451 return;
452 }
453 for path_item in openapi.paths.paths.values_mut() {
454 for op in path_item_operations_mut(path_item) {
455 apply_contribution_to_operation(op, c);
456 }
457 }
458}
459
460pub(crate) fn path_item_operations(
463 path_item: &utoipa::openapi::path::PathItem,
464) -> impl Iterator<Item = &Operation> {
465 [
466 path_item.get.as_ref(),
467 path_item.put.as_ref(),
468 path_item.post.as_ref(),
469 path_item.delete.as_ref(),
470 path_item.options.as_ref(),
471 path_item.head.as_ref(),
472 path_item.patch.as_ref(),
473 path_item.trace.as_ref(),
474 ]
475 .into_iter()
476 .flatten()
477}
478
479pub(crate) fn path_item_operations_mut(
484 path_item: &mut utoipa::openapi::path::PathItem,
485) -> impl Iterator<Item = &mut Operation> {
486 [
487 path_item.get.as_mut(),
488 path_item.put.as_mut(),
489 path_item.post.as_mut(),
490 path_item.delete.as_mut(),
491 path_item.options.as_mut(),
492 path_item.head.as_mut(),
493 path_item.patch.as_mut(),
494 path_item.trace.as_mut(),
495 ]
496 .into_iter()
497 .flatten()
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use utoipa::openapi::path::OperationBuilder;
504 use utoipa::openapi::response::Responses;
505
506 fn empty_op() -> Operation {
507 let mut op = OperationBuilder::new().build();
508 op.responses = Responses::new();
509 op
510 }
511
512 #[test]
513 fn apply_contribution_adds_headers_responses_security_tags_to_operation() {
514 let mut op = empty_op();
515 let c = LayerContribution::new()
516 .with_header(HeaderParam::required("Authorization"))
517 .with_response(ResponseContribution::unauthorized())
518 .with_security(SecurityContribution::new("bearer"))
519 .with_tag("auth");
520
521 apply_contribution_to_operation(&mut op, &c);
522
523 let params = op.parameters.expect("parameters set");
524 assert!(params.iter().any(|p| p.name == "Authorization"));
525 assert!(op.responses.responses.contains_key("401"));
526 let security = op.security.expect("security set");
527 assert_eq!(security.len(), 1);
528 let tags = op.tags.expect("tags set");
529 assert_eq!(tags, vec!["auth".to_string()]);
530 }
531
532 #[test]
533 fn apply_contribution_skips_response_status_already_declared_by_handler() {
534 let mut op = empty_op();
535 op.responses.responses.insert(
536 "401".to_string(),
537 RefOr::T(Response::new("handler-declared 401")),
538 );
539
540 let c = LayerContribution::new().with_response(ResponseContribution::unauthorized());
541 apply_contribution_to_operation(&mut op, &c);
542
543 let resp = op
544 .responses
545 .responses
546 .get("401")
547 .expect("401 still present");
548 match resp {
549 RefOr::T(r) => assert_eq!(r.description, "handler-declared 401"),
550 RefOr::Ref(_) => panic!("expected inline response"),
551 }
552 }
553
554 #[test]
555 fn apply_contribution_dedupes_security_requirement_when_called_twice() {
556 let mut op = empty_op();
557 let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
558
559 apply_contribution_to_operation(&mut op, &c);
560 apply_contribution_to_operation(&mut op, &c);
561
562 let security = op.security.expect("security set");
563 assert_eq!(security.len(), 1, "duplicate security requirement");
564 }
565
566 #[test]
567 fn apply_contribution_dedupes_tag() {
568 let mut op = empty_op();
569 let c = LayerContribution::new().with_tag("auth");
570
571 apply_contribution_to_operation(&mut op, &c);
572 apply_contribution_to_operation(&mut op, &c);
573
574 let tags = op.tags.expect("tags set");
575 assert_eq!(tags, vec!["auth".to_string()]);
576 }
577
578 #[test]
579 fn merge_contribution_concatenates_each_kind_in_order() {
580 let mut a = LayerContribution::new()
581 .with_header(HeaderParam::required("X-A"))
582 .with_tag("a");
583 let b = LayerContribution::new()
584 .with_header(HeaderParam::required("X-B"))
585 .with_tag("b");
586 a.merge(b);
587
588 assert_eq!(a.headers.len(), 2);
589 assert_eq!(a.headers[0].name, "X-A");
590 assert_eq!(a.headers[1].name, "X-B");
591 assert_eq!(a.tags, vec!["a".to_string(), "b".to_string()]);
592 }
593
594 #[test]
595 fn default_contribution_is_empty_no_op() {
596 let c = LayerContribution::default();
597 assert!(c.is_empty());
598
599 let mut openapi = utoipa::openapi::OpenApiBuilder::new().build();
600 apply_contribution(&mut openapi, &c);
601 assert!(openapi.paths.paths.is_empty());
602 }
603
604 #[test]
605 fn response_contribution_with_schema_ref_emits_json_content() {
606 let r = ResponseContribution::unauthorized()
607 .with_schema_ref("#/components/schemas/ApiErrorBody");
608 let resp = r.to_response();
609 let content = resp
610 .content
611 .get("application/json")
612 .expect("application/json content present");
613 match &content.schema {
614 Some(RefOr::Ref(_)) => {}
615 _ => panic!("expected $ref schema"),
616 }
617 }
618
619 #[test]
620 fn scoped_extractor_then_bare_layer_produces_single_entry() {
621 let mut op = empty_op();
622
623 record_required_permission(&mut op, "bearer", "widgets.read", "Read widgets");
625
626 let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
628 apply_contribution_to_operation(&mut op, &c);
629
630 let security = op.security.expect("security set");
631 assert_eq!(
632 security.len(),
633 1,
634 "bare layer entry should merge into scoped extractor entry"
635 );
636
637 let json = serde_json::to_value(&security[0]).unwrap();
639 let scopes = json.get("bearer").unwrap().as_array().unwrap();
640 assert_eq!(scopes, &[serde_json::json!("widgets.read")]);
641 }
642
643 #[test]
644 fn bare_layer_then_scoped_extractor_produces_single_entry() {
645 let mut op = empty_op();
646
647 let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
649 apply_contribution_to_operation(&mut op, &c);
650
651 record_required_permission(&mut op, "bearer", "widgets.write", "Write widgets");
653
654 let security = op.security.expect("security set");
655 assert_eq!(
656 security.len(),
657 1,
658 "scoped extractor entry should merge into bare layer entry"
659 );
660
661 let json = serde_json::to_value(&security[0]).unwrap();
662 let scopes = json.get("bearer").unwrap().as_array().unwrap();
663 assert_eq!(scopes, &[serde_json::json!("widgets.write")]);
664 }
665
666 #[test]
667 fn multiple_scopes_merge_into_single_entry() {
668 let mut op = empty_op();
669
670 record_required_permission(&mut op, "bearer", "widgets.read", "Read");
671 record_required_permission(&mut op, "bearer", "widgets.write", "Write");
672
673 let c = LayerContribution::new().with_security(SecurityContribution::new("bearer"));
674 apply_contribution_to_operation(&mut op, &c);
675
676 let security = op.security.expect("security set");
677 assert_eq!(security.len(), 1);
678
679 let json = serde_json::to_value(&security[0]).unwrap();
680 let scopes = json.get("bearer").unwrap().as_array().unwrap();
681 assert!(scopes.contains(&serde_json::json!("widgets.read")));
682 assert!(scopes.contains(&serde_json::json!("widgets.write")));
683 }
684}