Skip to main content

baukit_openapi/
error_responses.rs

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
10/// The response header that carries the request identifier.
11pub const REQUEST_ID_HEADER: &str = "X-Request-Id";
12
13/// The response header that tells a client how many seconds to wait before retrying.
14pub const RETRY_AFTER_HEADER: &str = "Retry-After";
15
16/// The response header that carries an authentication challenge.
17pub 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/// A property of an operation that decides whether a rule applies to it.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum OperationCondition {
28    /// Every operation.
29    Always,
30    /// The operation's own `security`, or the document's when the operation has none, contains a
31    /// requirement that names a scheme. An operation that also accepts anonymous calls still
32    /// counts, because a bad credential is rejected.
33    Secured,
34    /// The operation declares a request body.
35    HasRequestBody,
36    /// The path template has a parameter, such as `/items/{id}`.
37    HasPathParameter,
38    /// The method is not safe in the RFC 9110 sense: `POST`, `PUT`, `PATCH`, or `DELETE`.
39    UnsafeMethod,
40    /// The operation declares an `Idempotency-Key` header parameter.
41    HasIdempotencyKey,
42}
43
44/// Which responses of a matching operation a header rule documents.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum ResponseSelector {
47    /// Every inline response, including success responses.
48    Every,
49    /// The inline response with this status code.
50    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/// Error responses and response headers to document on every matching operation, as data.
69///
70/// A status rule adds a response with the shared [`ErrorEnvelope`] schema and the product's
71/// description when the operation does not document that status yet. A header rule adds a header
72/// to the selected inline responses when they do not have a header of that name, compared without
73/// regard to case. Responses given as a `$ref` are left unchanged, because they are shared
74/// components. Applying the same rules twice changes nothing.
75#[derive(Clone, Default)]
76pub struct ErrorResponseRules {
77    statuses: Vec<StatusRule>,
78    headers: Vec<HeaderRule>,
79}
80
81impl ErrorResponseRules {
82    /// Creates an empty rule set.
83    #[must_use]
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Adds an error response with this status and description to operations that match.
89    #[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    /// Adds a response header to the selected responses of operations that match.
105    #[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    /// Adds the headers that `baukit-http` and `baukit-auth` send: `X-Request-Id` on every
123    /// response, `Retry-After` on 429, and `WWW-Authenticate` on 401.
124    #[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    /// Applies the rules to every operation in the document.
147    pub fn apply(&self, document: &mut OpenApi) {
148        self.apply_where(document, |_, _| true);
149    }
150
151    /// Applies the rules to the operations whose path template and method pass `include`.
152    ///
153    /// Use it for statuses only some routes return, such as a 412 on conditional writes, or to
154    /// skip public documents such as the OpenAPI route itself.
155    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                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(), 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
272/// Adds an error response with the shared envelope unless the operation documents the status.
273pub(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/// Returns the documented `X-Request-Id` response header.
293#[must_use]
294pub fn request_id_header() -> Header {
295    HeaderBuilder::new()
296        .schema(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/// Returns the documented `Retry-After` response header in delta seconds.
305#[must_use]
306pub fn retry_after_header() -> Header {
307    HeaderBuilder::new()
308        .schema(
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/// Returns the documented `WWW-Authenticate` response header for a bearer challenge.
318#[must_use]
319pub fn www_authenticate_header() -> Header {
320    HeaderBuilder::new()
321        .schema(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}