Skip to main content

baukit_openapi/
precondition.rs

1use utoipa::openapi::header::{Header, HeaderBuilder};
2use utoipa::openapi::path::{Operation, Parameter, ParameterBuilder, ParameterIn};
3use utoipa::openapi::{ObjectBuilder, RefOr, Required, Type};
4
5use crate::error_responses::insert_error_response;
6
7/// The request header that carries a revision precondition.
8pub const IF_MATCH_HEADER: &str = "If-Match";
9
10/// The response header that carries a strong revision validator.
11pub const ETAG_HEADER: &str = "ETag";
12
13/// Error code for a required `If-Match` header that is missing (HTTP 428).
14pub const PRECONDITION_REQUIRED_CODE: &str = "precondition_required";
15
16/// Error code for an `If-Match` revision that is no longer current (HTTP 412).
17pub const PRECONDITION_FAILED_CODE: &str = "precondition_failed";
18
19/// Error code for an `If-Match` value that is not one strong ETag for the resource (HTTP 400).
20pub const INVALID_IF_MATCH_CODE: &str = "invalid_if_match";
21
22const STATUS_BAD_REQUEST: &str = "400";
23const STATUS_PRECONDITION_FAILED: &str = "412";
24const STATUS_PRECONDITION_REQUIRED: &str = "428";
25const SUCCESS_STATUS_PREFIX: char = '2';
26
27/// Whether a route rejects a write that omits `If-Match`.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum IfMatchRequirement {
30    /// A missing header is rejected with 428.
31    Required,
32    /// A missing header allows an unconditional write.
33    Optional,
34}
35
36impl IfMatchRequirement {
37    const fn is_required(self) -> bool {
38        matches!(self, Self::Required)
39    }
40}
41
42/// Returns the documented `If-Match` header parameter for a revision precondition.
43#[must_use]
44pub fn if_match_parameter(requirement: IfMatchRequirement) -> Parameter {
45    let missing = match requirement {
46        IfMatchRequirement::Required => {
47            format!("A missing header returns 428 `{PRECONDITION_REQUIRED_CODE}`.")
48        }
49        IfMatchRequirement::Optional => "Omit it to write unconditionally.".to_owned(),
50    };
51    let required = if requirement.is_required() {
52        Required::True
53    } else {
54        Required::False
55    };
56    ParameterBuilder::new()
57        .name(IF_MATCH_HEADER)
58        .parameter_in(ParameterIn::Header)
59        .required(required)
60        .description(Some(format!(
61            "The strong ETag from the latest read of this resource. {missing} A stale ETag \
62             returns 412 `{PRECONDITION_FAILED_CODE}`. `*`, weak ETags, lists, and ETags of \
63             another resource return 400 `{INVALID_IF_MATCH_CODE}`."
64        )))
65        .schema(Some(ObjectBuilder::new().schema_type(Type::String)))
66        .build()
67}
68
69/// Returns the documented `ETag` response header for a strong revision validator.
70#[must_use]
71pub fn etag_header() -> Header {
72    HeaderBuilder::new()
73        .schema(Some(ObjectBuilder::new().schema_type(Type::String)))
74        .description(Some(
75            "Strong revision validator. Send it unchanged in If-Match to update or delete this \
76             resource.",
77        ))
78        .build()
79}
80
81/// Documents a revision precondition on one operation.
82///
83/// Replaces any existing `If-Match` header parameter, matched without regard to case, and adds
84/// 400, 412, and, for required routes, 428 responses with the shared error envelope. Responses
85/// the operation already documents are kept.
86pub fn document_if_match(operation: &mut Operation, requirement: IfMatchRequirement) {
87    let parameters = operation.parameters.get_or_insert_with(Vec::new);
88    parameters.retain(|parameter| !is_if_match(parameter));
89    parameters.push(RefOr::T(if_match_parameter(requirement)));
90
91    insert_error_response(
92        operation,
93        STATUS_BAD_REQUEST,
94        &format!("`{INVALID_IF_MATCH_CODE}`: If-Match is not one strong ETag for this resource"),
95    );
96    insert_error_response(
97        operation,
98        STATUS_PRECONDITION_FAILED,
99        &format!("`{PRECONDITION_FAILED_CODE}`: the resource changed since it was read"),
100    );
101    if requirement.is_required() {
102        insert_error_response(
103            operation,
104            STATUS_PRECONDITION_REQUIRED,
105            &format!("`{PRECONDITION_REQUIRED_CODE}`: If-Match is required"),
106        );
107    }
108}
109
110/// Documents the `ETag` header on every inline 2xx response of one operation.
111///
112/// Responses given as a `$ref` are left unchanged, because they are shared components.
113pub fn document_etag(operation: &mut Operation) {
114    for (status, response) in &mut operation.responses.responses {
115        if !status.starts_with(SUCCESS_STATUS_PREFIX) {
116            continue;
117        }
118        if let RefOr::T(response) = response {
119            response
120                .headers
121                .insert(ETAG_HEADER.to_owned(), RefOr::T(etag_header()));
122        }
123    }
124}
125
126fn is_if_match(parameter: &RefOr<Parameter>) -> bool {
127    matches!(parameter, RefOr::T(parameter) if parameter.parameter_in == ParameterIn::Header
128        && parameter.name.eq_ignore_ascii_case(IF_MATCH_HEADER))
129}
130
131#[cfg(test)]
132mod tests {
133    use serde_json::{Value, json};
134    use utoipa::OpenApi as _;
135
136    use super::{
137        ETAG_HEADER, IF_MATCH_HEADER, IfMatchRequirement, document_etag, document_if_match,
138    };
139    use crate::{ErrorEnvelope, serialize_schema};
140
141    #[derive(serde::Serialize, utoipa::ToSchema)]
142    struct Item {
143        name: String,
144    }
145
146    #[expect(
147        dead_code,
148        reason = "OpenAPI path stubs: only their annotations are read, by the derived ApiDoc"
149    )]
150    mod stubs {
151        use super::{ErrorEnvelope, Item};
152
153        #[utoipa::path(
154            put,
155            path = "/items/{id}",
156            params(
157                ("id" = String, Path, description = "Item id"),
158                ("if-match" = Option<String>, Header, description = "Hand-written")
159            ),
160            responses(
161                (status = 200, description = "Updated", body = Item),
162                (status = 400, description = "Invalid item", body = ErrorEnvelope),
163                (status = 404, description = "Missing", body = ErrorEnvelope)
164            )
165        )]
166        fn update_item() {}
167
168        #[utoipa::path(
169            patch,
170            path = "/settings",
171            responses((status = 200, description = "Updated", body = Item))
172        )]
173        fn patch_settings() {}
174
175        #[utoipa::path(
176            get,
177            path = "/items/{id}",
178            params(("id" = String, Path, description = "Item id")),
179            responses(
180                (status = 200, description = "Found", body = Item),
181                (status = 404, description = "Missing", body = ErrorEnvelope)
182            )
183        )]
184        fn get_item() {}
185    }
186
187    #[derive(utoipa::OpenApi)]
188    #[openapi(
189        paths(stubs::update_item, stubs::patch_settings, stubs::get_item),
190        components(schemas(ErrorEnvelope, Item))
191    )]
192    struct ApiDoc;
193
194    fn documented() -> Value {
195        let mut document = ApiDoc::openapi();
196        let paths = &mut document.paths.paths;
197        let item = paths.get_mut("/items/{id}").expect("item path");
198        document_if_match(
199            item.put.as_mut().expect("put"),
200            IfMatchRequirement::Required,
201        );
202        document_etag(item.put.as_mut().expect("put"));
203        document_etag(item.get.as_mut().expect("get"));
204        let settings = paths.get_mut("/settings").expect("settings path");
205        document_if_match(
206            settings.patch.as_mut().expect("patch"),
207            IfMatchRequirement::Optional,
208        );
209        let json = serialize_schema(&document).expect("serializable");
210        serde_json::from_str(&json).expect("valid JSON")
211    }
212
213    fn if_match_parameters(operation: &Value) -> Vec<&Value> {
214        operation["parameters"]
215            .as_array()
216            .expect("parameters")
217            .iter()
218            .filter(|parameter| {
219                parameter["in"] == "header"
220                    && parameter["name"]
221                        .as_str()
222                        .is_some_and(|name| name.eq_ignore_ascii_case(IF_MATCH_HEADER))
223            })
224            .collect()
225    }
226
227    #[test]
228    fn required_precondition_replaces_hand_written_parameter_and_adds_errors() {
229        let document = documented();
230        let put = &document["paths"]["/items/{id}"]["put"];
231
232        let parameters = if_match_parameters(put);
233        assert_eq!(parameters.len(), 1);
234        assert_eq!(parameters[0]["name"], IF_MATCH_HEADER);
235        assert_eq!(parameters[0]["required"], true);
236        assert_eq!(parameters[0]["schema"], json!({"type": "string"}));
237        let description = parameters[0]["description"].as_str().expect("description");
238        for code in [
239            "precondition_required",
240            "precondition_failed",
241            "invalid_if_match",
242        ] {
243            assert!(description.contains(code), "{code}");
244        }
245
246        let responses = &put["responses"];
247        assert_eq!(responses["400"]["description"], "Invalid item");
248        for status in ["412", "428"] {
249            assert_eq!(
250                responses[status]["content"]["application/json"]["schema"]["$ref"],
251                "#/components/schemas/ErrorEnvelope",
252                "{status}"
253            );
254        }
255        assert_eq!(responses["404"]["description"], "Missing");
256    }
257
258    #[test]
259    fn optional_precondition_documents_no_428() {
260        let document = documented();
261        let patch = &document["paths"]["/settings"]["patch"];
262
263        let parameters = if_match_parameters(patch);
264        assert_eq!(parameters.len(), 1);
265        assert_eq!(parameters[0]["required"], false);
266        let responses = patch["responses"].as_object().expect("responses");
267        assert!(responses.contains_key("400"));
268        assert!(responses.contains_key("412"));
269        assert!(!responses.contains_key("428"));
270    }
271
272    #[test]
273    fn etag_is_documented_on_success_responses_only() {
274        let document = documented();
275        for method in ["put", "get"] {
276            let responses = &document["paths"]["/items/{id}"][method]["responses"];
277            assert_eq!(
278                responses["200"]["headers"][ETAG_HEADER]["schema"],
279                json!({"type": "string"}),
280                "{method}"
281            );
282            assert!(responses["404"].get("headers").is_none(), "{method}");
283        }
284        let settings = &document["paths"]["/settings"]["patch"]["responses"]["200"];
285        assert!(settings.get("headers").is_none());
286    }
287
288    #[test]
289    fn documenting_twice_is_stable() {
290        let mut document = ApiDoc::openapi();
291        let item = document
292            .paths
293            .paths
294            .get_mut("/items/{id}")
295            .expect("item path");
296        let put = item.put.as_mut().expect("put");
297        document_if_match(put, IfMatchRequirement::Optional);
298        document_if_match(put, IfMatchRequirement::Required);
299        document_etag(put);
300        document_etag(put);
301        let operation = serde_json::to_value(put.clone()).expect("serializable");
302        assert_eq!(if_match_parameters(&operation).len(), 1);
303        assert_eq!(if_match_parameters(&operation)[0]["required"], true);
304        assert_eq!(
305            operation["responses"]["200"]["headers"]
306                .as_object()
307                .expect("headers")
308                .len(),
309            1
310        );
311    }
312}