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    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
282/// Adds an error response with the shared envelope unless the operation documents the status.
283pub(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/// Returns the documented `X-Request-Id` response header.
303#[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/// Returns the documented `Retry-After` response header in delta seconds.
315#[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/// Returns the documented `WWW-Authenticate` response header for a bearer challenge.
328#[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}