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 secured: bool,
187 has_request_body: bool,
188 has_path_parameter: bool,
189 unsafe_method: bool,
190 has_idempotency_key: bool,
191}
192
193impl OperationFacts {
194 fn new(
195 path: &str,
196 method: &HttpMethod,
197 operation: &Operation,
198 document_security: &Option<Vec<SecurityRequirement>>,
199 ) -> Self {
200 let security = operation.security.as_ref().or(document_security.as_ref());
201 Self {
202 secured: security.is_some_and(|requirements| requirements.iter().any(names_a_scheme)),
203 has_request_body: operation.request_body.is_some(),
204 has_path_parameter: path.contains(PATH_PARAMETER_START),
205 unsafe_method: matches!(
206 method,
207 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete
208 ),
209 has_idempotency_key: operation.parameters.iter().flatten().any(|parameter| {
210 matches!(parameter, RefOr::T(parameter) if parameter.parameter_in == ParameterIn::Header
211 && parameter.name.eq_ignore_ascii_case(IDEMPOTENCY_KEY_HEADER))
212 }),
213 }
214 }
215
216 const fn matches(&self, condition: OperationCondition) -> bool {
217 match condition {
218 OperationCondition::Always => true,
219 OperationCondition::Secured => self.secured,
220 OperationCondition::HasRequestBody => self.has_request_body,
221 OperationCondition::HasPathParameter => self.has_path_parameter,
222 OperationCondition::UnsafeMethod => self.unsafe_method,
223 OperationCondition::HasIdempotencyKey => self.has_idempotency_key,
224 }
225 }
226}
227
228fn names_a_scheme(requirement: &SecurityRequirement) -> bool {
229 *requirement != SecurityRequirement::default()
230}
231
232fn operations_mut(item: &mut PathItem) -> [(HttpMethod, &mut Option<Operation>); 8] {
233 [
234 (HttpMethod::Get, &mut item.get),
235 (HttpMethod::Put, &mut item.put),
236 (HttpMethod::Post, &mut item.post),
237 (HttpMethod::Delete, &mut item.delete),
238 (HttpMethod::Options, &mut item.options),
239 (HttpMethod::Head, &mut item.head),
240 (HttpMethod::Patch, &mut item.patch),
241 (HttpMethod::Trace, &mut item.trace),
242 ]
243}
244
245fn insert_header(operation: &mut Operation, rule: &HeaderRule) {
246 for (status, response) in &mut operation.responses.responses {
247 let RefOr::T(response) = response else {
248 continue;
249 };
250 if !selects(rule.responses, status) {
251 continue;
252 }
253 let present = response
254 .headers
255 .keys()
256 .any(|name| name.eq_ignore_ascii_case(&rule.name));
257 if !present {
258 response
259 .headers
260 .insert(rule.name.clone(), RefOr::T(rule.header.clone()));
261 }
262 }
263}
264
265fn selects(selector: ResponseSelector, status: &str) -> bool {
266 match selector {
267 ResponseSelector::Every => true,
268 ResponseSelector::Status(selected) => status.parse::<u16>() == Ok(selected),
269 }
270}
271
272pub(crate) fn insert_error_response(operation: &mut Operation, status: &str, description: &str) {
274 operation
275 .responses
276 .responses
277 .entry(status.to_owned())
278 .or_insert_with(|| RefOr::T(error_response(description)));
279}
280
281fn error_response(description: &str) -> Response {
282 let schema = Ref::from_schema_name(<ErrorEnvelope as ToSchema>::name());
283 ResponseBuilder::new()
284 .description(description)
285 .content(
286 JSON_CONTENT_TYPE,
287 ContentBuilder::new().schema(Some(schema)).build(),
288 )
289 .build()
290}
291
292#[must_use]
294pub fn request_id_header() -> Header {
295 HeaderBuilder::new()
296 .schema(Some(ObjectBuilder::new().schema_type(Type::String)))
297 .description(Some(
298 "Request identifier. Error bodies repeat it as `requestId`; quote it when reporting a \
299 problem.",
300 ))
301 .build()
302}
303
304#[must_use]
306pub fn retry_after_header() -> Header {
307 HeaderBuilder::new()
308 .schema(Some(
309 ObjectBuilder::new()
310 .schema_type(Type::Integer)
311 .minimum(Some(0)),
312 ))
313 .description(Some("Seconds to wait before retrying."))
314 .build()
315}
316
317#[must_use]
319pub fn www_authenticate_header() -> Header {
320 HeaderBuilder::new()
321 .schema(Some(ObjectBuilder::new().schema_type(Type::String)))
322 .description(Some("Bearer authentication challenge."))
323 .build()
324}
325
326#[cfg(test)]
327mod tests {
328 use serde_json::{Value, json};
329 use utoipa::openapi::OpenApi;
330 use utoipa::openapi::path::HttpMethod;
331
332 use super::{
333 ErrorResponseRules, OperationCondition, REQUEST_ID_HEADER, RETRY_AFTER_HEADER,
334 ResponseSelector, WWW_AUTHENTICATE_HEADER, request_id_header,
335 };
336 use crate::{IfMatchRequirement, document_if_match};
337
338 const ENVELOPE: &str = "#/components/schemas/ErrorEnvelope";
339
340 fn ok() -> Value {
341 json!({"200": {"description": "OK"}})
342 }
343
344 fn document(paths: Value) -> OpenApi {
345 serde_json::from_value(json!({
346 "openapi": "3.1.0",
347 "info": {"title": "test", "version": "1"},
348 "paths": paths
349 }))
350 .expect("test document")
351 }
352
353 fn to_json(document: &OpenApi) -> Value {
354 serde_json::to_value(document).expect("serialize")
355 }
356
357 fn has_status(document: &Value, path: &str, method: &str, status: &str) -> bool {
358 document["paths"][path][method]["responses"]
359 .get(status)
360 .is_some()
361 }
362
363 fn matched(condition: OperationCondition, paths: Value) -> Vec<(String, String)> {
364 let mut document = document(paths);
365 ErrorResponseRules::new()
366 .status(condition, 418, "Matched")
367 .apply(&mut document);
368 let json = to_json(&document);
369 let mut matched = Vec::new();
370 for (path, item) in json["paths"].as_object().expect("paths") {
371 for method in item.as_object().expect("item").keys() {
372 if has_status(&json, path, method, "418") {
373 matched.push((method.clone(), path.clone()));
374 }
375 }
376 }
377 matched.sort();
378 matched
379 }
380
381 fn pairs(expected: &[(&str, &str)]) -> Vec<(String, String)> {
382 expected
383 .iter()
384 .map(|(method, path)| ((*method).to_owned(), (*path).to_owned()))
385 .collect()
386 }
387
388 #[test]
389 fn status_rule_adds_an_envelope_response_with_the_product_description() {
390 let mut document = document(json!({"/items": {"get": {"responses": ok()}}}));
391
392 ErrorResponseRules::new()
393 .status(OperationCondition::Always, 500, "Internal failure")
394 .apply(&mut document);
395
396 let response = &to_json(&document)["paths"]["/items"]["get"]["responses"]["500"];
397 assert_eq!(response["description"], "Internal failure");
398 assert_eq!(
399 response["content"]["application/json"]["schema"]["$ref"],
400 ENVELOPE
401 );
402 }
403
404 #[test]
405 fn status_rule_keeps_a_status_the_handler_documents() {
406 let mut document = document(json!({"/items": {"post": {"responses": {
407 "400": {"description": "Invalid item"}
408 }}}}));
409
410 ErrorResponseRules::new()
411 .status(OperationCondition::Always, 400, "Generic")
412 .apply(&mut document);
413
414 assert_eq!(
415 to_json(&document)["paths"]["/items"]["post"]["responses"]["400"],
416 json!({"description": "Invalid item"})
417 );
418 }
419
420 #[test]
421 fn secured_follows_operation_then_document_security_and_ignores_anonymous_only() {
422 let mut document = document(json!({
423 "/inherited": {"get": {"responses": ok()}},
424 "/own": {"get": {"responses": ok(), "security": [{"apiKey": []}]}},
425 "/public": {"get": {"responses": ok(), "security": []}},
426 "/anonymous": {"get": {"responses": ok(), "security": [{}]}},
427 "/optional": {"get": {"responses": ok(), "security": [{}, {"bearer": []}]}}
428 }));
429 document.security = serde_json::from_value(json!([{"bearer": []}])).expect("security");
430
431 ErrorResponseRules::new()
432 .status(OperationCondition::Secured, 401, "Unauthenticated")
433 .apply(&mut document);
434
435 let json = to_json(&document);
436 for (path, secured) in [
437 ("/inherited", true),
438 ("/own", true),
439 ("/public", false),
440 ("/anonymous", false),
441 ("/optional", true),
442 ] {
443 assert_eq!(has_status(&json, path, "get", "401"), secured, "{path}");
444 }
445 }
446
447 #[test]
448 fn secured_is_false_without_any_security() {
449 let paths = json!({"/items": {"get": {"responses": ok()}}});
450
451 assert!(matched(OperationCondition::Secured, paths).is_empty());
452 }
453
454 #[test]
455 fn request_body_path_parameter_and_unsafe_method_conditions() {
456 let paths = json!({
457 "/items": {
458 "get": {"responses": ok()},
459 "post": {"responses": ok(), "requestBody": {"content": {}}}
460 },
461 "/items/{id}": {
462 "get": {"responses": ok()},
463 "put": {"responses": ok()},
464 "patch": {"responses": ok()},
465 "delete": {"responses": ok()},
466 "head": {"responses": ok()},
467 "options": {"responses": ok()}
468 }
469 });
470
471 assert_eq!(
472 matched(OperationCondition::HasRequestBody, paths.clone()),
473 pairs(&[("post", "/items")])
474 );
475 assert_eq!(
476 matched(OperationCondition::HasPathParameter, paths.clone()).len(),
477 6
478 );
479 assert_eq!(
480 matched(OperationCondition::UnsafeMethod, paths),
481 pairs(&[
482 ("delete", "/items/{id}"),
483 ("patch", "/items/{id}"),
484 ("post", "/items"),
485 ("put", "/items/{id}"),
486 ])
487 );
488 }
489
490 #[test]
491 fn idempotency_key_condition_matches_the_header_without_regard_to_case() {
492 let paths = json!({
493 "/a": {"post": {"responses": ok(), "parameters": [
494 {"name": "idempotency-key", "in": "header", "required": false}
495 ]}},
496 "/b": {"post": {"responses": ok(), "parameters": [
497 {"name": "Idempotency-Key", "in": "query", "required": false}
498 ]}},
499 "/c": {"post": {"responses": ok()}}
500 });
501
502 assert_eq!(
503 matched(OperationCondition::HasIdempotencyKey, paths),
504 pairs(&[("post", "/a")])
505 );
506 }
507
508 #[test]
509 fn standard_headers_document_request_id_retry_after_and_challenge() {
510 let mut document = document(json!({"/items": {"get": {
511 "responses": ok(),
512 "security": [{"bearer": []}]
513 }}}));
514
515 ErrorResponseRules::new()
516 .status(OperationCondition::Secured, 401, "Unauthenticated")
517 .status(OperationCondition::Always, 429, "Too many requests")
518 .standard_headers()
519 .apply(&mut document);
520
521 let responses = &to_json(&document)["paths"]["/items"]["get"]["responses"];
522 for status in ["200", "401", "429"] {
523 assert_eq!(
524 responses[status]["headers"][REQUEST_ID_HEADER]["schema"],
525 json!({"type": "string"}),
526 "{status}"
527 );
528 }
529 assert_eq!(
530 responses["429"]["headers"][RETRY_AFTER_HEADER]["schema"],
531 json!({"type": "integer", "minimum": 0})
532 );
533 assert!(responses["401"]["headers"][WWW_AUTHENTICATE_HEADER].is_object());
534 assert!(
535 responses["200"]["headers"]
536 .get(RETRY_AFTER_HEADER)
537 .is_none()
538 );
539 assert!(
540 responses["429"]["headers"]
541 .get(WWW_AUTHENTICATE_HEADER)
542 .is_none()
543 );
544 }
545
546 #[test]
547 fn header_rule_keeps_an_existing_header_of_any_case_and_skips_references() {
548 let mut document = document(json!({"/items": {"get": {"responses": {
549 "200": {"description": "OK", "headers": {
550 "x-request-id": {"schema": {"type": "string"}, "description": "Product"}
551 }},
552 "500": {"$ref": "#/components/responses/Internal"}
553 }}}}));
554
555 ErrorResponseRules::new()
556 .header(
557 OperationCondition::Always,
558 ResponseSelector::Every,
559 REQUEST_ID_HEADER,
560 request_id_header(),
561 )
562 .apply(&mut document);
563
564 let responses = &to_json(&document)["paths"]["/items"]["get"]["responses"];
565 assert_eq!(
566 responses["200"]["headers"],
567 json!({"x-request-id": {"schema": {"type": "string"}, "description": "Product"}})
568 );
569 assert_eq!(
570 responses["500"],
571 json!({"$ref": "#/components/responses/Internal"})
572 );
573 }
574
575 #[test]
576 fn applying_twice_changes_nothing() {
577 let rules = ErrorResponseRules::new()
578 .status(OperationCondition::Always, 400, "Invalid")
579 .status(OperationCondition::HasPathParameter, 404, "Missing")
580 .standard_headers();
581 let mut document = document(json!({"/items/{id}": {"get": {"responses": ok()}}}));
582
583 rules.apply(&mut document);
584 let once = to_json(&document);
585 rules.apply(&mut document);
586
587 assert_eq!(to_json(&document), once);
588 }
589
590 #[test]
591 fn apply_where_limits_rules_to_selected_operations() {
592 let mut document = document(json!({
593 "/items/{id}": {"get": {"responses": ok()}, "patch": {"responses": ok()}},
594 "/settings": {"patch": {"responses": ok()}}
595 }));
596
597 ErrorResponseRules::new()
598 .status(OperationCondition::Always, 412, "Stale revision")
599 .apply_where(&mut document, |path, method| {
600 *method == HttpMethod::Patch && path.starts_with("/items/")
601 });
602
603 let json = to_json(&document);
604 assert!(has_status(&json, "/items/{id}", "patch", "412"));
605 assert!(!has_status(&json, "/items/{id}", "get", "412"));
606 assert!(!has_status(&json, "/settings", "patch", "412"));
607 }
608
609 #[test]
610 fn precondition_responses_keep_their_descriptions_and_gain_rule_headers() {
611 let mut document = document(json!({"/items/{id}": {"put": {"responses": ok()}}}));
612 let operation = document
613 .paths
614 .paths
615 .get_mut("/items/{id}")
616 .and_then(|item| item.put.as_mut())
617 .expect("put");
618 document_if_match(operation, IfMatchRequirement::Required);
619 let before = to_json(&document)["paths"]["/items/{id}"]["put"]["responses"].clone();
620
621 ErrorResponseRules::new()
622 .status(OperationCondition::Always, 400, "Generic")
623 .status(OperationCondition::Always, 412, "Generic")
624 .standard_headers()
625 .apply(&mut document);
626
627 let after = &to_json(&document)["paths"]["/items/{id}"]["put"]["responses"];
628 for status in ["400", "412", "428"] {
629 assert_eq!(
630 after[status]["description"], before[status]["description"],
631 "{status}"
632 );
633 assert_eq!(
634 after[status]["content"], before[status]["content"],
635 "{status}"
636 );
637 assert!(after[status]["headers"][REQUEST_ID_HEADER].is_object());
638 }
639 }
640}