1use utoipa::ToSchema;
2use utoipa::openapi::header::{Header, HeaderBuilder};
3use utoipa::openapi::path::{HttpMethod, Operation, ParameterIn, PathItem};
4use utoipa::openapi::response::{Response, ResponseBuilder};
5use utoipa::openapi::security::SecurityRequirement;
6use utoipa::openapi::{ContentBuilder, ObjectBuilder, OpenApi, Ref, RefOr, Type};
7
8use crate::ErrorEnvelope;
9
10pub const REQUEST_ID_HEADER: &str = "X-Request-Id";
12
13pub const RETRY_AFTER_HEADER: &str = "Retry-After";
15
16pub const WWW_AUTHENTICATE_HEADER: &str = "WWW-Authenticate";
18
19const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key";
20const JSON_CONTENT_TYPE: &str = "application/json";
21const PATH_PARAMETER_START: char = '{';
22const STATUS_UNAUTHORIZED: u16 = 401;
23const STATUS_TOO_MANY_REQUESTS: u16 = 429;
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum OperationCondition {
28 Always,
30 Secured,
34 HasRequestBody,
36 HasPathParameter,
38 UnsafeMethod,
40 HasIdempotencyKey,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum ResponseSelector {
47 Every,
49 Status(u16),
51}
52
53#[derive(Clone, Debug)]
54struct StatusRule {
55 condition: OperationCondition,
56 status: String,
57 description: String,
58}
59
60#[derive(Clone)]
61struct HeaderRule {
62 condition: OperationCondition,
63 responses: ResponseSelector,
64 name: String,
65 header: Header,
66}
67
68#[derive(Clone, Default)]
76pub struct ErrorResponseRules {
77 statuses: Vec<StatusRule>,
78 headers: Vec<HeaderRule>,
79}
80
81impl ErrorResponseRules {
82 #[must_use]
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 #[must_use]
90 pub fn status(
91 mut self,
92 condition: OperationCondition,
93 status: u16,
94 description: impl Into<String>,
95 ) -> Self {
96 self.statuses.push(StatusRule {
97 condition,
98 status: status.to_string(),
99 description: description.into(),
100 });
101 self
102 }
103
104 #[must_use]
106 pub fn header(
107 mut self,
108 condition: OperationCondition,
109 responses: ResponseSelector,
110 name: impl Into<String>,
111 header: Header,
112 ) -> Self {
113 self.headers.push(HeaderRule {
114 condition,
115 responses,
116 name: name.into(),
117 header,
118 });
119 self
120 }
121
122 #[must_use]
125 pub fn standard_headers(self) -> Self {
126 self.header(
127 OperationCondition::Always,
128 ResponseSelector::Every,
129 REQUEST_ID_HEADER,
130 request_id_header(),
131 )
132 .header(
133 OperationCondition::Always,
134 ResponseSelector::Status(STATUS_TOO_MANY_REQUESTS),
135 RETRY_AFTER_HEADER,
136 retry_after_header(),
137 )
138 .header(
139 OperationCondition::Always,
140 ResponseSelector::Status(STATUS_UNAUTHORIZED),
141 WWW_AUTHENTICATE_HEADER,
142 www_authenticate_header(),
143 )
144 }
145
146 pub fn apply(&self, document: &mut OpenApi) {
148 self.apply_where(document, |_, _| true);
149 }
150
151 pub fn apply_where(&self, document: &mut OpenApi, include: impl Fn(&str, &HttpMethod) -> bool) {
156 let document_security = document.security.clone();
157 for (path, item) in &mut document.paths.paths {
158 for (method, operation) in operations_mut(item) {
159 let Some(operation) = operation else {
160 continue;
161 };
162 if !include(path, &method) {
163 continue;
164 }
165 let facts = OperationFacts::new(path, &method, operation, &document_security);
166 self.apply_to_operation(&facts, operation);
167 }
168 }
169 }
170
171 fn apply_to_operation(&self, facts: &OperationFacts, operation: &mut Operation) {
172 for rule in &self.statuses {
173 if facts.matches(rule.condition) {
174 insert_error_response(operation, &rule.status, &rule.description);
175 }
176 }
177 for rule in &self.headers {
178 if facts.matches(rule.condition) {
179 insert_header(operation, rule);
180 }
181 }
182 }
183}
184
185struct OperationFacts {
186 satisfied: Vec<OperationCondition>,
187}
188
189impl OperationFacts {
190 fn new(
191 path: &str,
192 method: &HttpMethod,
193 operation: &Operation,
194 document_security: &Option<Vec<SecurityRequirement>>,
195 ) -> Self {
196 let security = operation.security.as_ref().or(document_security.as_ref());
197 let facts = [
198 (
199 OperationCondition::Secured,
200 security.is_some_and(|requirements| requirements.iter().any(names_a_scheme)),
201 ),
202 (
203 OperationCondition::HasRequestBody,
204 operation.request_body.is_some(),
205 ),
206 (
207 OperationCondition::HasPathParameter,
208 path.contains(PATH_PARAMETER_START),
209 ),
210 (
211 OperationCondition::UnsafeMethod,
212 matches!(
213 method,
214 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete
215 ),
216 ),
217 (
218 OperationCondition::HasIdempotencyKey,
219 operation.parameters.iter().flatten().any(|parameter| {
220 matches!(parameter, RefOr::T(parameter) if parameter.parameter_in == ParameterIn::Header
221 && parameter.name.eq_ignore_ascii_case(IDEMPOTENCY_KEY_HEADER))
222 }),
223 ),
224 ];
225 Self {
226 satisfied: facts
227 .into_iter()
228 .filter_map(|(condition, holds)| holds.then_some(condition))
229 .collect(),
230 }
231 }
232
233 fn matches(&self, condition: OperationCondition) -> bool {
234 condition == OperationCondition::Always || self.satisfied.contains(&condition)
235 }
236}
237
238fn names_a_scheme(requirement: &SecurityRequirement) -> bool {
239 *requirement != SecurityRequirement::default()
240}
241
242fn operations_mut(item: &mut PathItem) -> [(HttpMethod, &mut Option<Operation>); 8] {
243 [
244 (HttpMethod::Get, &mut item.get),
245 (HttpMethod::Put, &mut item.put),
246 (HttpMethod::Post, &mut item.post),
247 (HttpMethod::Delete, &mut item.delete),
248 (HttpMethod::Options, &mut item.options),
249 (HttpMethod::Head, &mut item.head),
250 (HttpMethod::Patch, &mut item.patch),
251 (HttpMethod::Trace, &mut item.trace),
252 ]
253}
254
255fn insert_header(operation: &mut Operation, rule: &HeaderRule) {
256 for (status, response) in &mut operation.responses.responses {
257 let RefOr::T(response) = response else {
258 continue;
259 };
260 if !selects(rule.responses, status) {
261 continue;
262 }
263 let present = response
264 .headers
265 .keys()
266 .any(|name| name.eq_ignore_ascii_case(&rule.name));
267 if !present {
268 response
269 .headers
270 .insert(rule.name.clone(), RefOr::T(rule.header.clone()));
271 }
272 }
273}
274
275fn selects(selector: ResponseSelector, status: &str) -> bool {
276 match selector {
277 ResponseSelector::Every => true,
278 ResponseSelector::Status(selected) => status.parse::<u16>() == Ok(selected),
279 }
280}
281
282pub(crate) fn insert_error_response(operation: &mut Operation, status: &str, description: &str) {
284 operation
285 .responses
286 .responses
287 .entry(status.to_owned())
288 .or_insert_with(|| RefOr::T(error_response(description)));
289}
290
291fn error_response(description: &str) -> Response {
292 let schema = Ref::from_schema_name(<ErrorEnvelope as ToSchema>::name());
293 ResponseBuilder::new()
294 .description(description)
295 .content(
296 JSON_CONTENT_TYPE,
297 ContentBuilder::new().schema(Some(schema)).build(),
298 )
299 .build()
300}
301
302#[must_use]
304pub fn request_id_header() -> Header {
305 HeaderBuilder::new()
306 .schema(Some(ObjectBuilder::new().schema_type(Type::String)))
307 .description(Some(
308 "Request identifier. Error bodies repeat it as `requestId`; quote it when reporting a \
309 problem.",
310 ))
311 .build()
312}
313
314#[must_use]
316pub fn retry_after_header() -> Header {
317 HeaderBuilder::new()
318 .schema(Some(
319 ObjectBuilder::new()
320 .schema_type(Type::Integer)
321 .minimum(Some(0)),
322 ))
323 .description(Some("Seconds to wait before retrying."))
324 .build()
325}
326
327#[must_use]
329pub fn www_authenticate_header() -> Header {
330 HeaderBuilder::new()
331 .schema(Some(ObjectBuilder::new().schema_type(Type::String)))
332 .description(Some("Bearer authentication challenge."))
333 .build()
334}
335
336#[cfg(test)]
337mod tests {
338 use serde_json::{Value, json};
339 use utoipa::openapi::OpenApi;
340 use utoipa::openapi::path::HttpMethod;
341
342 use super::{
343 ErrorResponseRules, OperationCondition, REQUEST_ID_HEADER, RETRY_AFTER_HEADER,
344 ResponseSelector, WWW_AUTHENTICATE_HEADER, request_id_header,
345 };
346 use crate::{IfMatchRequirement, document_if_match};
347
348 const ENVELOPE: &str = "#/components/schemas/ErrorEnvelope";
349
350 fn ok() -> Value {
351 json!({"200": {"description": "OK"}})
352 }
353
354 fn document(paths: &Value) -> OpenApi {
355 serde_json::from_value(json!({
356 "openapi": "3.1.0",
357 "info": {"title": "test", "version": "1"},
358 "paths": paths
359 }))
360 .expect("test document")
361 }
362
363 fn to_json(document: &OpenApi) -> Value {
364 serde_json::to_value(document).expect("serialize")
365 }
366
367 fn has_status(document: &Value, path: &str, method: &str, status: &str) -> bool {
368 document["paths"][path][method]["responses"]
369 .get(status)
370 .is_some()
371 }
372
373 fn matched(condition: OperationCondition, paths: &Value) -> Vec<(String, String)> {
374 let mut document = document(paths);
375 ErrorResponseRules::new()
376 .status(condition, 418, "Matched")
377 .apply(&mut document);
378 let json = to_json(&document);
379 let mut matched = Vec::new();
380 for (path, item) in json["paths"].as_object().expect("paths") {
381 for method in item.as_object().expect("item").keys() {
382 if has_status(&json, path, method, "418") {
383 matched.push((method.clone(), path.clone()));
384 }
385 }
386 }
387 matched.sort();
388 matched
389 }
390
391 fn pairs(expected: &[(&str, &str)]) -> Vec<(String, String)> {
392 expected
393 .iter()
394 .map(|(method, path)| ((*method).to_owned(), (*path).to_owned()))
395 .collect()
396 }
397
398 #[test]
399 fn status_rule_adds_an_envelope_response_with_the_product_description() {
400 let mut document = document(&json!({"/items": {"get": {"responses": ok()}}}));
401
402 ErrorResponseRules::new()
403 .status(OperationCondition::Always, 500, "Internal failure")
404 .apply(&mut document);
405
406 let response = &to_json(&document)["paths"]["/items"]["get"]["responses"]["500"];
407 assert_eq!(response["description"], "Internal failure");
408 assert_eq!(
409 response["content"]["application/json"]["schema"]["$ref"],
410 ENVELOPE
411 );
412 }
413
414 #[test]
415 fn status_rule_keeps_a_status_the_handler_documents() {
416 let mut document = document(&json!({"/items": {"post": {"responses": {
417 "400": {"description": "Invalid item"}
418 }}}}));
419
420 ErrorResponseRules::new()
421 .status(OperationCondition::Always, 400, "Generic")
422 .apply(&mut document);
423
424 assert_eq!(
425 to_json(&document)["paths"]["/items"]["post"]["responses"]["400"],
426 json!({"description": "Invalid item"})
427 );
428 }
429
430 #[test]
431 fn secured_follows_operation_then_document_security_and_ignores_anonymous_only() {
432 let mut document = document(&json!({
433 "/inherited": {"get": {"responses": ok()}},
434 "/own": {"get": {"responses": ok(), "security": [{"apiKey": []}]}},
435 "/public": {"get": {"responses": ok(), "security": []}},
436 "/anonymous": {"get": {"responses": ok(), "security": [{}]}},
437 "/optional": {"get": {"responses": ok(), "security": [{}, {"bearer": []}]}}
438 }));
439 document.security = serde_json::from_value(json!([{"bearer": []}])).expect("security");
440
441 ErrorResponseRules::new()
442 .status(OperationCondition::Secured, 401, "Unauthenticated")
443 .apply(&mut document);
444
445 let json = to_json(&document);
446 for (path, secured) in [
447 ("/inherited", true),
448 ("/own", true),
449 ("/public", false),
450 ("/anonymous", false),
451 ("/optional", true),
452 ] {
453 assert_eq!(has_status(&json, path, "get", "401"), secured, "{path}");
454 }
455 }
456
457 #[test]
458 fn secured_is_false_without_any_security() {
459 let paths = json!({"/items": {"get": {"responses": ok()}}});
460
461 assert!(matched(OperationCondition::Secured, &paths).is_empty());
462 }
463
464 #[test]
465 fn request_body_path_parameter_and_unsafe_method_conditions() {
466 let paths = json!({
467 "/items": {
468 "get": {"responses": ok()},
469 "post": {"responses": ok(), "requestBody": {"content": {}}}
470 },
471 "/items/{id}": {
472 "get": {"responses": ok()},
473 "put": {"responses": ok()},
474 "patch": {"responses": ok()},
475 "delete": {"responses": ok()},
476 "head": {"responses": ok()},
477 "options": {"responses": ok()}
478 }
479 });
480
481 assert_eq!(
482 matched(OperationCondition::HasRequestBody, &paths.clone()),
483 pairs(&[("post", "/items")])
484 );
485 assert_eq!(
486 matched(OperationCondition::HasPathParameter, &paths.clone()).len(),
487 6
488 );
489 assert_eq!(
490 matched(OperationCondition::UnsafeMethod, &paths),
491 pairs(&[
492 ("delete", "/items/{id}"),
493 ("patch", "/items/{id}"),
494 ("post", "/items"),
495 ("put", "/items/{id}"),
496 ])
497 );
498 }
499
500 #[test]
501 fn idempotency_key_condition_matches_the_header_without_regard_to_case() {
502 let paths = json!({
503 "/a": {"post": {"responses": ok(), "parameters": [
504 {"name": "idempotency-key", "in": "header", "required": false}
505 ]}},
506 "/b": {"post": {"responses": ok(), "parameters": [
507 {"name": "Idempotency-Key", "in": "query", "required": false}
508 ]}},
509 "/c": {"post": {"responses": ok()}}
510 });
511
512 assert_eq!(
513 matched(OperationCondition::HasIdempotencyKey, &paths),
514 pairs(&[("post", "/a")])
515 );
516 }
517
518 #[test]
519 fn standard_headers_document_request_id_retry_after_and_challenge() {
520 let mut document = document(&json!({"/items": {"get": {
521 "responses": ok(),
522 "security": [{"bearer": []}]
523 }}}));
524
525 ErrorResponseRules::new()
526 .status(OperationCondition::Secured, 401, "Unauthenticated")
527 .status(OperationCondition::Always, 429, "Too many requests")
528 .standard_headers()
529 .apply(&mut document);
530
531 let responses = &to_json(&document)["paths"]["/items"]["get"]["responses"];
532 for status in ["200", "401", "429"] {
533 assert_eq!(
534 responses[status]["headers"][REQUEST_ID_HEADER]["schema"],
535 json!({"type": "string"}),
536 "{status}"
537 );
538 }
539 assert_eq!(
540 responses["429"]["headers"][RETRY_AFTER_HEADER]["schema"],
541 json!({"type": "integer", "minimum": 0})
542 );
543 assert!(responses["401"]["headers"][WWW_AUTHENTICATE_HEADER].is_object());
544 assert!(
545 responses["200"]["headers"]
546 .get(RETRY_AFTER_HEADER)
547 .is_none()
548 );
549 assert!(
550 responses["429"]["headers"]
551 .get(WWW_AUTHENTICATE_HEADER)
552 .is_none()
553 );
554 }
555
556 #[test]
557 fn header_rule_keeps_an_existing_header_of_any_case_and_skips_references() {
558 let mut document = document(&json!({"/items": {"get": {"responses": {
559 "200": {"description": "OK", "headers": {
560 "x-request-id": {"schema": {"type": "string"}, "description": "Product"}
561 }},
562 "500": {"$ref": "#/components/responses/Internal"}
563 }}}}));
564
565 ErrorResponseRules::new()
566 .header(
567 OperationCondition::Always,
568 ResponseSelector::Every,
569 REQUEST_ID_HEADER,
570 request_id_header(),
571 )
572 .apply(&mut document);
573
574 let responses = &to_json(&document)["paths"]["/items"]["get"]["responses"];
575 assert_eq!(
576 responses["200"]["headers"],
577 json!({"x-request-id": {"schema": {"type": "string"}, "description": "Product"}})
578 );
579 assert_eq!(
580 responses["500"],
581 json!({"$ref": "#/components/responses/Internal"})
582 );
583 }
584
585 #[test]
586 fn applying_twice_changes_nothing() {
587 let rules = ErrorResponseRules::new()
588 .status(OperationCondition::Always, 400, "Invalid")
589 .status(OperationCondition::HasPathParameter, 404, "Missing")
590 .standard_headers();
591 let mut document = document(&json!({"/items/{id}": {"get": {"responses": ok()}}}));
592
593 rules.apply(&mut document);
594 let once = to_json(&document);
595 rules.apply(&mut document);
596
597 assert_eq!(to_json(&document), once);
598 }
599
600 #[test]
601 fn apply_where_limits_rules_to_selected_operations() {
602 let mut document = document(&json!({
603 "/items/{id}": {"get": {"responses": ok()}, "patch": {"responses": ok()}},
604 "/settings": {"patch": {"responses": ok()}}
605 }));
606
607 ErrorResponseRules::new()
608 .status(OperationCondition::Always, 412, "Stale revision")
609 .apply_where(&mut document, |path, method| {
610 *method == HttpMethod::Patch && path.starts_with("/items/")
611 });
612
613 let json = to_json(&document);
614 assert!(has_status(&json, "/items/{id}", "patch", "412"));
615 assert!(!has_status(&json, "/items/{id}", "get", "412"));
616 assert!(!has_status(&json, "/settings", "patch", "412"));
617 }
618
619 #[test]
620 fn precondition_responses_keep_their_descriptions_and_gain_rule_headers() {
621 let mut document = document(&json!({"/items/{id}": {"put": {"responses": ok()}}}));
622 let operation = document
623 .paths
624 .paths
625 .get_mut("/items/{id}")
626 .and_then(|item| item.put.as_mut())
627 .expect("put");
628 document_if_match(operation, IfMatchRequirement::Required);
629 let before = to_json(&document)["paths"]["/items/{id}"]["put"]["responses"].clone();
630
631 ErrorResponseRules::new()
632 .status(OperationCondition::Always, 400, "Generic")
633 .status(OperationCondition::Always, 412, "Generic")
634 .standard_headers()
635 .apply(&mut document);
636
637 let after = &to_json(&document)["paths"]["/items/{id}"]["put"]["responses"];
638 for status in ["400", "412", "428"] {
639 assert_eq!(
640 after[status]["description"], before[status]["description"],
641 "{status}"
642 );
643 assert_eq!(
644 after[status]["content"], before[status]["content"],
645 "{status}"
646 );
647 assert!(after[status]["headers"][REQUEST_ID_HEADER].is_object());
648 }
649 }
650}