use crate::{Api, OperationParameter, SchemaKind, SchemaValue};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const RESOLVED_ANNOTATION: &str = "x-poolster-idempotency-resolved";
fn enabled() -> bool {
true
}
fn header() -> String {
"Idempotency-Key".into()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct IdempotencyRule {
#[serde(default = "enabled")]
pub enabled: bool,
#[serde(default = "header")]
pub header: String,
#[serde(default)]
pub auto_generate: bool,
}
impl Default for IdempotencyRule {
fn default() -> Self {
Self {
enabled: true,
header: header(),
auto_generate: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IdempotencyConfig {
#[serde(default)]
pub defaults: Option<IdempotencyRule>,
#[serde(default)]
pub operations: BTreeMap<String, IdempotencyRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ResolvedIdempotency {
pub header: String,
pub parameter_name: String,
pub auto_generate: bool,
}
pub fn resolved(operation: &crate::Operation) -> Option<ResolvedIdempotency> {
let value = operation.annotations.get(RESOLVED_ANNOTATION)?;
serde_json::from_value(value.clone()).ok()
}
fn valid_header(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
}
pub fn prepare_api(api: &Api, config: &IdempotencyConfig) -> Result<Api> {
for id in config.operations.keys() {
let count = api
.operations
.iter()
.filter(|operation| &operation.id == id)
.count();
if count != 1 {
bail!(
"idempotency operation {id:?} must identify exactly one operation (found {count})"
);
}
}
let mut prepared = api.clone();
for operation in &mut prepared.operations {
operation.annotations.remove(RESOLVED_ANNOTATION);
let extension = operation
.annotations
.get("x-poolster-idempotency")
.filter(|_| !config.operations.contains_key(&operation.id) && config.defaults.is_none())
.map(|value| match value {
serde_json::Value::Bool(value) => Ok(IdempotencyRule {
enabled: *value,
..Default::default()
}),
_ => serde_json::from_value(value.clone())
.context("x-poolster-idempotency must be a boolean or rule object"),
})
.transpose()
.with_context(|| format!("idempotency for {}", operation.id))?;
let rule = config
.operations
.get(&operation.id)
.or(config.defaults.as_ref())
.or(extension.as_ref());
let Some(rule) = rule.filter(|rule| rule.enabled) else {
continue;
};
if !valid_header(&rule.header) {
bail!("invalid idempotency header name for {}", operation.id);
}
if matches!(
rule.header.to_ascii_lowercase().as_str(),
"authorization"
| "proxy-authorization"
| "host"
| "content-type"
| "content-length"
| "transfer-encoding"
| "connection"
| "cookie"
| "set-cookie"
| "trailer"
| "te"
| "upgrade"
| "accept"
| "accept-encoding"
| "content-encoding"
| "user-agent"
) {
bail!(
"idempotency policy for {} cannot use a transport/authentication header",
operation.id
);
}
let identifier_key = |name: &str| {
name.bytes()
.filter(u8::is_ascii_alphanumeric)
.map(|byte| byte.to_ascii_lowercase())
.collect::<Vec<_>>()
};
let key = identifier_key(&rule.header);
if key.is_empty() {
bail!(
"idempotency header for {} must contain an ASCII alphanumeric identifier",
operation.id
);
}
if operation.parameters.iter().any(|parameter| {
identifier_key(¶meter.name) == key
&& (parameter.location != "header"
|| !parameter.name.eq_ignore_ascii_case(&rule.header))
}) {
bail!(
"idempotency header for {} collides with another parameter identifier",
operation.id
);
}
let matching: Vec<_> = operation
.parameters
.iter()
.enumerate()
.filter(|(_, parameter)| {
parameter.location == "header" && parameter.name.eq_ignore_ascii_case(&rule.header)
})
.map(|(index, _)| index)
.collect();
if matching.len() > 1 {
bail!(
"duplicate idempotency header parameters for {}",
operation.id
);
}
let name = if let Some(index) = matching.first() {
let parameter = &operation.parameters[*index];
let schema = parameter
.schema
.as_ref()
.context("idempotency header requires a string schema")?;
if !matches!(schema.kind, SchemaKind::String)
|| schema.nullable
|| schema.nullish
|| schema.optional
|| !schema.enum_values.is_empty()
|| schema.const_value.is_some()
|| schema.format.is_some()
|| !schema.constraints.is_empty()
|| schema.default.is_some()
{
bail!(
"idempotency header for {} requires a direct unconstrained string schema",
operation.id
);
}
if parameter.required {
bail!("idempotency header for {} must be optional", operation.id);
}
parameter.name.clone()
} else {
operation.parameters.push(OperationParameter {
name: rule.header.clone(),
location: "header".into(),
required: false,
schema: Some(SchemaValue::new(SchemaKind::String)),
description: Some("Caller-supplied idempotency key for this operation.".into()),
annotations: BTreeMap::new(),
});
rule.header.clone()
};
let resolved = serde_json::to_value(ResolvedIdempotency {
header: name.clone(),
parameter_name: name,
auto_generate: rule.auto_generate,
})?;
operation
.annotations
.insert(RESOLVED_ANNOTATION.into(), resolved);
}
Ok(prepared)
}
#[cfg(test)]
mod tests {
use super::*;
fn api() -> Api {
let mut api = Api::default();
api.operations.push(crate::Operation {
id: "create".into(),
method: crate::HttpMethod::Post,
path: "/items".into(),
parameters: vec![],
request_body: None,
responses: vec![],
security: vec![],
annotations: BTreeMap::new(),
});
api
}
#[test]
fn opt_in_precedence_and_input_are_preserved() {
let mut source = api();
assert!(
prepare_api(&source, &Default::default())
.unwrap()
.operations[0]
.parameters
.is_empty()
);
source.operations[0].annotations.insert(
"x-poolster-idempotency".into(),
serde_json::json!({"auto_generate":true}),
);
let prepared = prepare_api(&source, &Default::default()).unwrap();
assert_eq!(prepared.operations[0].parameters[0].name, "Idempotency-Key");
assert!(
prepared.operations[0].annotations[RESOLVED_ANNOTATION]["auto_generate"]
.as_bool()
.unwrap()
);
assert!(source.operations[0].parameters.is_empty());
let mut canonical = api();
canonical.operations[0].annotations.insert(
"x-poolster-idempotency".into(),
serde_json::json!({"auto_generate":true}),
);
let canonical = prepare_api(&canonical, &Default::default()).unwrap();
assert!(resolved(&canonical.operations[0]).unwrap().auto_generate);
let config = IdempotencyConfig {
defaults: Some(IdempotencyRule {
enabled: false,
..Default::default()
}),
..Default::default()
};
assert!(
prepare_api(&source, &config).unwrap().operations[0]
.parameters
.is_empty()
);
}
#[test]
fn overrides_ignore_source_policy_and_untrusted_resolution() {
let mut source = api();
source.operations[0].annotations.insert(
"x-poolster-idempotency".into(),
serde_json::json!({"unknown":true}),
);
source.operations[0].annotations.insert(
RESOLVED_ANNOTATION.into(),
serde_json::json!({"header":"Fake", "parameter_name":"Fake", "auto_generate":true}),
);
assert!(prepare_api(&source, &Default::default()).is_err());
let config = IdempotencyConfig {
defaults: Some(IdempotencyRule {
enabled: false,
..Default::default()
}),
operations: BTreeMap::from([(
"create".into(),
IdempotencyRule {
header: "X-Request-Key".into(),
..Default::default()
},
)]),
};
let prepared = prepare_api(&source, &config).unwrap();
assert_eq!(
resolved(&prepared.operations[0]).unwrap().header,
"X-Request-Key"
);
let disabled = IdempotencyConfig {
defaults: Some(IdempotencyRule {
enabled: false,
..Default::default()
}),
..Default::default()
};
assert!(resolved(&prepare_api(&source, &disabled).unwrap().operations[0]).is_none());
for header in ["", "X-Key\r\nInjected", "contains space", "ümlaut"] {
let config = IdempotencyConfig {
defaults: Some(IdempotencyRule {
header: header.into(),
..Default::default()
}),
..Default::default()
};
assert!(prepare_api(&api(), &config).is_err());
}
}
#[test]
fn rejects_reserved_headers_and_parameter_identifier_collisions() {
for header in [
"Authorization",
"content-TYPE",
"Host",
"Cookie",
"Content-Length",
"---",
] {
let config = IdempotencyConfig {
defaults: Some(IdempotencyRule {
header: header.into(),
..Default::default()
}),
..Default::default()
};
assert!(prepare_api(&api(), &config).is_err());
}
for name in ["Idempotency-Key", "idempotencyKey", "IDEMPOTENCY_KEY"] {
let mut source = api();
source.operations[0].parameters.push(OperationParameter {
name: name.into(),
location: "query".into(),
required: false,
schema: Some(SchemaValue::new(SchemaKind::String)),
description: None,
annotations: BTreeMap::new(),
});
let config = IdempotencyConfig {
defaults: Some(Default::default()),
..Default::default()
};
assert!(prepare_api(&source, &config).is_err());
}
}
#[test]
fn header_matching_validation_and_unknown_operation() {
let mut source = api();
source.operations[0]
.annotations
.insert("x-poolster-idempotency".into(), serde_json::json!(true));
source = prepare_api(&source, &Default::default()).unwrap();
source.operations[0].parameters[0].name = "idempotency-key".into();
let prepared = prepare_api(&source, &Default::default()).unwrap();
assert_eq!(prepared.operations[0].parameters.len(), 1);
assert_eq!(
prepared.operations[0].annotations[RESOLVED_ANNOTATION]["header"],
"idempotency-key"
);
source.operations[0].parameters[0].required = true;
assert!(prepare_api(&source, &Default::default()).is_err());
source.operations[0].parameters[0].required = false;
let duplicate = source.operations[0].parameters[0].clone();
source.operations[0].parameters.push(duplicate);
assert!(prepare_api(&source, &Default::default()).is_err());
let config = IdempotencyConfig {
operations: BTreeMap::from([("missing".into(), Default::default())]),
..Default::default()
};
assert!(prepare_api(&api(), &config).is_err());
}
}