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
7pub const IF_MATCH_HEADER: &str = "If-Match";
9
10pub const ETAG_HEADER: &str = "ETag";
12
13pub const PRECONDITION_REQUIRED_CODE: &str = "precondition_required";
15
16pub const PRECONDITION_FAILED_CODE: &str = "precondition_failed";
18
19pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum IfMatchRequirement {
30 Required,
32 Optional,
34}
35
36impl IfMatchRequirement {
37 const fn is_required(self) -> bool {
38 matches!(self, Self::Required)
39 }
40}
41
42#[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#[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
81pub 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
110pub 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}