Skip to main content

poolster_core/
idempotency.rs

1//! Explicit, package-local idempotency policy. Runtime generation consumes the
2//! resolved annotation; preparing an API never changes the caller's API.
3use 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}
49/// Read generator-resolved metadata. Call this on the API supplied by a package.
50pub 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}
61/// Recipe defaults override operation extensions; recipe operation rules override
62/// both. An absent policy is disabled. Explicit disabled rules suppress headers.
63pub 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(&parameter.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            // Preserve the wire header spelling; renderers apply their usual native identifier mapping.
193            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}