use serde_json::Value;
use crate::error::ToolError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdempotencyPath {
raw: String,
segments: Vec<String>,
}
impl IdempotencyPath {
pub fn parse(path: &str) -> Result<Self, IdempotencyPathError> {
if path.is_empty() {
return Err(IdempotencyPathError::Empty);
}
let segments: Vec<String> = path.split('.').map(ToOwned::to_owned).collect();
if segments.iter().any(String::is_empty) {
return Err(IdempotencyPathError::EmptySegment {
path: path.to_owned(),
});
}
Ok(Self {
raw: path.to_owned(),
segments,
})
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.raw
}
pub fn derive(&self, tool: &str, input: &Value) -> Result<String, ToolError> {
let refuse = |detail: String| ToolError::MissingIdempotencyKey {
tool: tool.to_owned(),
path: self.raw.clone(),
detail,
};
let mut current = input;
for (index, segment) in self.segments.iter().enumerate() {
let Some(object) = current.as_object() else {
return Err(refuse(format!(
"{} is not a JSON object, so `{segment}` cannot be looked up in it; {}",
location(&self.segments, index),
present_keys(input)
)));
};
let Some(next) = object.get(segment) else {
return Err(refuse(format!(
"there is no `{segment}` in {}; {}",
location(&self.segments, index),
present_keys(input)
)));
};
current = next;
}
match current {
Value::String(value) if !value.is_empty() => Ok(format!("{tool}:{value}")),
Value::Number(value) => Ok(format!("{tool}:{value}")),
other => Err(refuse(format!(
"`{}` holds {}, and an idempotency key must be a non-empty string or a number; {}",
self.raw,
describe(other),
present_keys(input)
))),
}
}
}
fn location(segments: &[String], index: usize) -> String {
if index == 0 {
"the call's input".to_owned()
} else {
format!("`{}`", segments[..index].join("."))
}
}
fn present_keys(input: &Value) -> String {
match input.as_object() {
Some(object) if object.is_empty() => "the input has no keys".to_owned(),
Some(object) => format!(
"the input's keys are: {}",
object.keys().cloned().collect::<Vec<_>>().join(", ")
),
None => format!("the input is not an object; it is {}", describe(input)),
}
}
fn describe(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(s) if s.is_empty() => "an empty string",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum IdempotencyPathError {
#[error(
"an idempotency key path cannot be empty; name the input field that identifies the operation, for example \"claim_id\" or \"payment.claim_id\""
)]
Empty,
#[error(
"idempotency key path `{path}` has an empty segment; write dotted paths as `payment.claim_id`, with no leading, trailing, or doubled dots"
)]
EmptySegment {
path: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_key_is_the_tool_name_and_the_field_value() {
let path = IdempotencyPath::parse("claim_id").expect("parses");
let key = path
.derive("pay_claim", &json!({"claim_id": "wreck-9931"}))
.expect("derives");
assert_eq!(key, "pay_claim:wreck-9931");
let path = IdempotencyPath::parse("invoice").expect("parses");
let key = path
.derive("charge", &json!({"invoice": 483_200}))
.expect("derives");
assert_eq!(key, "charge:483200");
}
#[test]
fn the_key_is_a_pure_function_of_the_named_field() {
let path = IdempotencyPath::parse("claim_id").expect("parses");
let first = path
.derive(
"pay_claim",
&json!({"claim_id": "wreck-9931", "amount_cents": 1}),
)
.expect("derives");
let second = path
.derive(
"pay_claim",
&json!({"amount_cents": 1, "claim_id": "wreck-9931"}),
)
.expect("derives");
assert_eq!(first, second);
}
#[test]
fn a_dotted_path_reads_a_nested_field() {
let path = IdempotencyPath::parse("payment.claim_id").expect("parses");
let key = path
.derive("pay_claim", &json!({"payment": {"claim_id": "wreck-9931"}}))
.expect("derives");
assert_eq!(key, "pay_claim:wreck-9931");
}
#[test]
fn a_missing_field_refuses_and_says_what_was_there() {
let path = IdempotencyPath::parse("claim_id").expect("parses");
let error = path
.derive(
"pay_claim",
&json!({"amount_cents": 483_200, "currency": "USD"}),
)
.expect_err("a missing key field must refuse");
let message = error.to_string();
assert!(message.contains("pay_claim"), "names the tool: {message}");
assert!(message.contains("claim_id"), "names the path: {message}");
assert!(
message.contains("amount_cents, currency"),
"names the keys present: {message}"
);
}
#[test]
fn a_nested_miss_names_where_it_stopped() {
let path = IdempotencyPath::parse("payment.claim_id").expect("parses");
let error = path
.derive("pay_claim", &json!({"payment": {"amount_cents": 1}}))
.expect_err("a missing nested field must refuse");
let message = error.to_string();
assert!(message.contains("`payment`"), "names the prefix: {message}");
assert!(
message.contains("no `claim_id`"),
"names the segment: {message}"
);
}
#[test]
fn a_value_that_cannot_be_an_identity_refuses() {
let path = IdempotencyPath::parse("claim_id").expect("parses");
for input in [
json!({"claim_id": true}),
json!({"claim_id": null}),
json!({"claim_id": ""}),
json!({"claim_id": ["wreck-9931"]}),
json!({"claim_id": {"id": "wreck-9931"}}),
] {
let error = path
.derive("pay_claim", &input)
.expect_err("only a non-empty string or a number is a key");
let message = error.to_string();
assert!(message.contains("claim_id"), "names the path: {message}");
assert!(
message.contains("non-empty string or a number"),
"teaches the rule: {message}"
);
}
}
#[test]
fn a_path_through_a_non_object_refuses() {
let path = IdempotencyPath::parse("payment.claim_id").expect("parses");
let error = path
.derive("pay_claim", &json!({"payment": "wreck-9931"}))
.expect_err("a scalar cannot be walked into");
assert!(
error.to_string().contains("is not a JSON object"),
"says what went wrong: {error}"
);
}
#[test]
fn a_malformed_path_fails_at_parse() {
assert_eq!(IdempotencyPath::parse(""), Err(IdempotencyPathError::Empty));
for path in ["a.", ".a", "a..b", "."] {
assert!(
matches!(
IdempotencyPath::parse(path),
Err(IdempotencyPathError::EmptySegment { .. })
),
"`{path}` must be rejected"
);
}
}
}