1use crate::{Api, OperationParameter, SchemaKind, SchemaValue};
4use anyhow::{Context, Result, bail};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8pub const RESOLVED_ANNOTATION: &str = "x-poolster-idempotency-resolved";
9fn enabled() -> bool {
10 true
11}
12fn header() -> String {
13 "Idempotency-Key".into()
14}
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(deny_unknown_fields)]
17pub struct IdempotencyRule {
18 #[serde(default = "enabled")]
19 pub enabled: bool,
20 #[serde(default = "header")]
21 pub header: String,
22 #[serde(default)]
23 pub auto_generate: bool,
24}
25impl Default for IdempotencyRule {
26 fn default() -> Self {
27 Self {
28 enabled: true,
29 header: header(),
30 auto_generate: false,
31 }
32 }
33}
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct IdempotencyConfig {
37 #[serde(default)]
38 pub defaults: Option<IdempotencyRule>,
39 #[serde(default)]
40 pub operations: BTreeMap<String, IdempotencyRule>,
41}
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(deny_unknown_fields)]
44pub struct ResolvedIdempotency {
45 pub header: String,
46 pub parameter_name: String,
47 pub auto_generate: bool,
48}
49pub fn resolved(operation: &crate::Operation) -> Option<ResolvedIdempotency> {
51 let value = operation.annotations.get(RESOLVED_ANNOTATION)?;
52 serde_json::from_value(value.clone()).ok()
53}
54
55fn valid_header(value: &str) -> bool {
56 !value.is_empty()
57 && value
58 .bytes()
59 .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
60}
61pub fn prepare_api(api: &Api, config: &IdempotencyConfig) -> Result<Api> {
64 for id in config.operations.keys() {
65 let count = api
66 .operations
67 .iter()
68 .filter(|operation| &operation.id == id)
69 .count();
70 if count != 1 {
71 bail!(
72 "idempotency operation {id:?} must identify exactly one operation (found {count})"
73 );
74 }
75 }
76 let mut prepared = api.clone();
77 for operation in &mut prepared.operations {
78 operation.annotations.remove(RESOLVED_ANNOTATION);
79 let extension = operation
80 .annotations
81 .get("x-poolster-idempotency")
82 .filter(|_| !config.operations.contains_key(&operation.id) && config.defaults.is_none())
83 .map(|value| match value {
84 serde_json::Value::Bool(value) => Ok(IdempotencyRule {
85 enabled: *value,
86 ..Default::default()
87 }),
88 _ => serde_json::from_value(value.clone())
89 .context("x-poolster-idempotency must be a boolean or rule object"),
90 })
91 .transpose()
92 .with_context(|| format!("idempotency for {}", operation.id))?;
93 let rule = config
94 .operations
95 .get(&operation.id)
96 .or(config.defaults.as_ref())
97 .or(extension.as_ref());
98 let Some(rule) = rule.filter(|rule| rule.enabled) else {
99 continue;
100 };
101 if !valid_header(&rule.header) {
102 bail!("invalid idempotency header name for {}", operation.id);
103 }
104 if matches!(
105 rule.header.to_ascii_lowercase().as_str(),
106 "authorization"
107 | "proxy-authorization"
108 | "host"
109 | "content-type"
110 | "content-length"
111 | "transfer-encoding"
112 | "connection"
113 | "cookie"
114 | "set-cookie"
115 | "trailer"
116 | "te"
117 | "upgrade"
118 | "accept"
119 | "accept-encoding"
120 | "content-encoding"
121 | "user-agent"
122 ) {
123 bail!(
124 "idempotency policy for {} cannot use a transport/authentication header",
125 operation.id
126 );
127 }
128 let identifier_key = |name: &str| {
129 name.bytes()
130 .filter(u8::is_ascii_alphanumeric)
131 .map(|byte| byte.to_ascii_lowercase())
132 .collect::<Vec<_>>()
133 };
134 let key = identifier_key(&rule.header);
135 if key.is_empty() {
136 bail!(
137 "idempotency header for {} must contain an ASCII alphanumeric identifier",
138 operation.id
139 );
140 }
141 if operation.parameters.iter().any(|parameter| {
142 identifier_key(¶meter.name) == key
143 && (parameter.location != "header"
144 || !parameter.name.eq_ignore_ascii_case(&rule.header))
145 }) {
146 bail!(
147 "idempotency header for {} collides with another parameter identifier",
148 operation.id
149 );
150 }
151 let matching: Vec<_> = operation
152 .parameters
153 .iter()
154 .enumerate()
155 .filter(|(_, parameter)| {
156 parameter.location == "header" && parameter.name.eq_ignore_ascii_case(&rule.header)
157 })
158 .map(|(index, _)| index)
159 .collect();
160 if matching.len() > 1 {
161 bail!(
162 "duplicate idempotency header parameters for {}",
163 operation.id
164 );
165 }
166 let name = if let Some(index) = matching.first() {
167 let parameter = &operation.parameters[*index];
168 let schema = parameter
169 .schema
170 .as_ref()
171 .context("idempotency header requires a string schema")?;
172 if !matches!(schema.kind, SchemaKind::String)
173 || schema.nullable
174 || schema.nullish
175 || schema.optional
176 || !schema.enum_values.is_empty()
177 || schema.const_value.is_some()
178 || schema.format.is_some()
179 || !schema.constraints.is_empty()
180 || schema.default.is_some()
181 {
182 bail!(
183 "idempotency header for {} requires a direct unconstrained string schema",
184 operation.id
185 );
186 }
187 if parameter.required {
188 bail!("idempotency header for {} must be optional", operation.id);
189 }
190 parameter.name.clone()
191 } else {
192 operation.parameters.push(OperationParameter {
194 name: rule.header.clone(),
195 location: "header".into(),
196 required: false,
197 schema: Some(SchemaValue::new(SchemaKind::String)),
198 description: Some("Caller-supplied idempotency key for this operation.".into()),
199 annotations: BTreeMap::new(),
200 });
201 rule.header.clone()
202 };
203 let resolved = serde_json::to_value(ResolvedIdempotency {
204 header: name.clone(),
205 parameter_name: name,
206 auto_generate: rule.auto_generate,
207 })?;
208 operation
209 .annotations
210 .insert(RESOLVED_ANNOTATION.into(), resolved);
211 }
212 Ok(prepared)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 fn api() -> Api {
219 let mut api = Api::default();
220 api.operations.push(crate::Operation {
221 id: "create".into(),
222 method: crate::HttpMethod::Post,
223 path: "/items".into(),
224 parameters: vec![],
225 request_body: None,
226 responses: vec![],
227 security: vec![],
228 annotations: BTreeMap::new(),
229 });
230 api
231 }
232 #[test]
233 fn opt_in_precedence_and_input_are_preserved() {
234 let mut source = api();
235 assert!(
236 prepare_api(&source, &Default::default())
237 .unwrap()
238 .operations[0]
239 .parameters
240 .is_empty()
241 );
242 source.operations[0].annotations.insert(
243 "x-poolster-idempotency".into(),
244 serde_json::json!({"auto_generate":true}),
245 );
246 let prepared = prepare_api(&source, &Default::default()).unwrap();
247 assert_eq!(prepared.operations[0].parameters[0].name, "Idempotency-Key");
248 assert!(
249 prepared.operations[0].annotations[RESOLVED_ANNOTATION]["auto_generate"]
250 .as_bool()
251 .unwrap()
252 );
253 assert!(source.operations[0].parameters.is_empty());
254 let mut canonical = api();
255 canonical.operations[0].annotations.insert(
256 "x-poolster-idempotency".into(),
257 serde_json::json!({"auto_generate":true}),
258 );
259 let canonical = prepare_api(&canonical, &Default::default()).unwrap();
260 assert!(resolved(&canonical.operations[0]).unwrap().auto_generate);
261 let config = IdempotencyConfig {
262 defaults: Some(IdempotencyRule {
263 enabled: false,
264 ..Default::default()
265 }),
266 ..Default::default()
267 };
268 assert!(
269 prepare_api(&source, &config).unwrap().operations[0]
270 .parameters
271 .is_empty()
272 );
273 }
274 #[test]
275 fn overrides_ignore_source_policy_and_untrusted_resolution() {
276 let mut source = api();
277 source.operations[0].annotations.insert(
278 "x-poolster-idempotency".into(),
279 serde_json::json!({"unknown":true}),
280 );
281 source.operations[0].annotations.insert(
282 RESOLVED_ANNOTATION.into(),
283 serde_json::json!({"header":"Fake", "parameter_name":"Fake", "auto_generate":true}),
284 );
285 assert!(prepare_api(&source, &Default::default()).is_err());
286 let config = IdempotencyConfig {
287 defaults: Some(IdempotencyRule {
288 enabled: false,
289 ..Default::default()
290 }),
291 operations: BTreeMap::from([(
292 "create".into(),
293 IdempotencyRule {
294 header: "X-Request-Key".into(),
295 ..Default::default()
296 },
297 )]),
298 };
299 let prepared = prepare_api(&source, &config).unwrap();
300 assert_eq!(
301 resolved(&prepared.operations[0]).unwrap().header,
302 "X-Request-Key"
303 );
304 let disabled = IdempotencyConfig {
305 defaults: Some(IdempotencyRule {
306 enabled: false,
307 ..Default::default()
308 }),
309 ..Default::default()
310 };
311 assert!(resolved(&prepare_api(&source, &disabled).unwrap().operations[0]).is_none());
312 for header in ["", "X-Key\r\nInjected", "contains space", "ümlaut"] {
313 let config = IdempotencyConfig {
314 defaults: Some(IdempotencyRule {
315 header: header.into(),
316 ..Default::default()
317 }),
318 ..Default::default()
319 };
320 assert!(prepare_api(&api(), &config).is_err());
321 }
322 }
323
324 #[test]
325 fn rejects_reserved_headers_and_parameter_identifier_collisions() {
326 for header in [
327 "Authorization",
328 "content-TYPE",
329 "Host",
330 "Cookie",
331 "Content-Length",
332 "---",
333 ] {
334 let config = IdempotencyConfig {
335 defaults: Some(IdempotencyRule {
336 header: header.into(),
337 ..Default::default()
338 }),
339 ..Default::default()
340 };
341 assert!(prepare_api(&api(), &config).is_err());
342 }
343 for name in ["Idempotency-Key", "idempotencyKey", "IDEMPOTENCY_KEY"] {
344 let mut source = api();
345 source.operations[0].parameters.push(OperationParameter {
346 name: name.into(),
347 location: "query".into(),
348 required: false,
349 schema: Some(SchemaValue::new(SchemaKind::String)),
350 description: None,
351 annotations: BTreeMap::new(),
352 });
353 let config = IdempotencyConfig {
354 defaults: Some(Default::default()),
355 ..Default::default()
356 };
357 assert!(prepare_api(&source, &config).is_err());
358 }
359 }
360
361 #[test]
362 fn header_matching_validation_and_unknown_operation() {
363 let mut source = api();
364 source.operations[0]
365 .annotations
366 .insert("x-poolster-idempotency".into(), serde_json::json!(true));
367 source = prepare_api(&source, &Default::default()).unwrap();
368 source.operations[0].parameters[0].name = "idempotency-key".into();
369 let prepared = prepare_api(&source, &Default::default()).unwrap();
370 assert_eq!(prepared.operations[0].parameters.len(), 1);
371 assert_eq!(
372 prepared.operations[0].annotations[RESOLVED_ANNOTATION]["header"],
373 "idempotency-key"
374 );
375 source.operations[0].parameters[0].required = true;
376 assert!(prepare_api(&source, &Default::default()).is_err());
377 source.operations[0].parameters[0].required = false;
378 let duplicate = source.operations[0].parameters[0].clone();
379 source.operations[0].parameters.push(duplicate);
380 assert!(prepare_api(&source, &Default::default()).is_err());
381 let config = IdempotencyConfig {
382 operations: BTreeMap::from([("missing".into(), Default::default())]),
383 ..Default::default()
384 };
385 assert!(prepare_api(&api(), &config).is_err());
386 }
387}