Skip to content

Commit 2dea022

Browse files
committed
feat(async-stripe-core): Create newtype for idempotency key
idempotency key cannot be empty and cannot be longer than 255 characters and having asserts before their usage is not the best error handling strategy. having it in a newtype guarantees that every use of this key is valid and no assert or runtime panic is needed. Refs: arlyon#662 (comment) Refs: https://docs.stripe.com/api/idempotent_requests Signed-off-by: Robin Ilyk <[email protected]>
1 parent 70934cc commit 2dea022

File tree

4 files changed

+71
-11
lines changed

4 files changed

+71
-11
lines changed

async-stripe-client-core/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ miniserde.workspace = true
2727
futures-util = "0.3.28"
2828
uuid = { version = "1.6.1", optional = true, features = ["v4"] }
2929
tracing = "0.1.40"
30+
thiserror = "2.0.11"

async-stripe-client-core/src/request_strategy.rs

+68-8
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub enum RequestStrategy {
1111
/// Run the request once.
1212
Once,
1313
/// Run it once with a given idempotency key.
14-
Idempotent(String),
14+
Idempotent(IdempotencyKey),
1515
/// This strategy will retry the request up to the
1616
/// specified number of times using the same, random,
1717
/// idempotency key, up to n times.
@@ -64,25 +64,83 @@ impl RequestStrategy {
6464
/// Send the request once with a generated UUID.
6565
#[cfg(feature = "uuid")]
6666
pub fn idempotent_with_uuid() -> Self {
67-
use uuid::Uuid;
68-
Self::Idempotent(Uuid::new_v4().to_string())
67+
Self::Idempotent(IdempotencyKey::new_uuid_v4())
6968
}
7069

7170
/// Extract the current idempotency key to use for the next request, if any.
72-
pub fn get_key(&self) -> Option<String> {
71+
pub fn get_key(&self) -> Option<IdempotencyKey> {
7372
match self {
7473
RequestStrategy::Once => None,
7574
RequestStrategy::Idempotent(key) => Some(key.clone()),
7675
#[cfg(feature = "uuid")]
7776
RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => {
78-
Some(uuid::Uuid::new_v4().to_string())
77+
Some(IdempotencyKey::new_uuid_v4())
7978
}
8079
#[cfg(not(feature = "uuid"))]
8180
RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => None,
8281
}
8382
}
8483
}
8584

85+
#[derive(Debug, Clone, PartialEq, Eq)]
86+
#[repr(transparent)]
87+
/// Represents valid idempotency key
88+
/// - Cannot be empty
89+
/// - Cannot be longer than 255 charachters
90+
pub struct IdempotencyKey(String);
91+
92+
#[derive(Debug, thiserror::Error)]
93+
/// Error that can be returned when constructing [`IdempotencyKey`]
94+
pub enum IdempotentKeyError {
95+
#[error("Idempotency Key cannot be empty")]
96+
/// Idempotency key cannot be empty
97+
EmptyKey,
98+
#[error("Idempotency key cannot be longer than 255 characters (you supplied: {0})")]
99+
/// Idempotency key cannot be longer than 255 characters
100+
KeyTooLong(usize),
101+
}
102+
103+
impl IdempotencyKey {
104+
/// Creates new validated idempotency key.
105+
/// - Cannot be empty
106+
/// - Cannot be longer than 255 charachters
107+
pub fn new(val: impl AsRef<str>) -> Result<Self, IdempotentKeyError> {
108+
let val = val.as_ref();
109+
if val.is_empty() {
110+
Err(IdempotentKeyError::EmptyKey)
111+
} else if val.len() > 255 {
112+
Err(IdempotentKeyError::KeyTooLong(val.len()))
113+
} else {
114+
Ok(Self(val.to_owned()))
115+
}
116+
}
117+
118+
#[cfg(feature = "uuid")]
119+
/// Generates new UUID as new idempotency key
120+
pub fn new_uuid_v4() -> Self {
121+
let uuid = uuid::Uuid::new_v4().to_string();
122+
Self(uuid)
123+
}
124+
125+
/// Borrows self as string slice
126+
pub fn as_str(&self) -> &str {
127+
&self.0
128+
}
129+
130+
/// Consumes self and returns inner string
131+
pub fn into_inner(self) -> String {
132+
self.0
133+
}
134+
}
135+
136+
impl TryFrom<String> for IdempotencyKey {
137+
type Error = IdempotentKeyError;
138+
139+
fn try_from(value: String) -> Result<Self, Self::Error> {
140+
Self::new(value)
141+
}
142+
}
143+
86144
fn calculate_backoff(retry_count: u32) -> Duration {
87145
Duration::from_secs(2_u64.pow(retry_count))
88146
}
@@ -102,11 +160,13 @@ mod tests {
102160
use std::time::Duration;
103161

104162
use super::{Outcome, RequestStrategy};
163+
use crate::IdempotencyKey;
105164

106165
#[test]
107166
fn test_idempotent_strategy() {
108-
let strategy = RequestStrategy::Idempotent("key".to_string());
109-
assert_eq!(strategy.get_key(), Some("key".to_string()));
167+
let key: IdempotencyKey = "key".to_string().try_into().unwrap();
168+
let strategy = RequestStrategy::Idempotent(key.clone());
169+
assert_eq!(strategy.get_key(), Some(key));
110170
}
111171

112172
#[test]
@@ -122,7 +182,7 @@ mod tests {
122182
fn test_uuid_idempotency() {
123183
use uuid::Uuid;
124184
let strategy = RequestStrategy::Retry(3);
125-
assert!(Uuid::parse_str(&strategy.get_key().unwrap()).is_ok());
185+
assert!(Uuid::parse_str(&strategy.get_key().unwrap().as_str()).is_ok());
126186
}
127187

128188
#[test]

async-stripe/src/async_std/client.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ impl Client {
7676
let mut last_error = StripeError::ClientError("Invalid strategy".to_string());
7777

7878
if let Some(key) = strategy.get_key() {
79-
request.insert_header("Idempotency-Key", key);
79+
request.insert_header("idempotency-key", key.as_str());
8080
}
8181

8282
let body = request.body_bytes().await?;

async-stripe/src/hyper/client.rs

+1-2
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,7 @@ impl Client {
110110

111111
if let Some(key) = strategy.get_key() {
112112
const HEADER_NAME: HeaderName = HeaderName::from_static("idempotency-key");
113-
assert!(!key.is_empty(), "idempotency key is empty");
114-
req_builder = req_builder.header(HEADER_NAME, key);
113+
req_builder = req_builder.header(HEADER_NAME, key.as_str());
115114
}
116115

117116
loop {

0 commit comments

Comments
 (0)