akahu_client/types.rs
1//! Type-safe wrappers for primitive types used throughout the Akahu API.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4
5// ============================================================================
6// Macros for generating NewTypes
7// ============================================================================
8
9/// Macro for creating simple NewTypes without validation
10macro_rules! newtype_string {
11 ($(#[$attr:meta])* $vis:vis $name:ident) => {
12 $(#[$attr])*
13 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14 #[serde(transparent)]
15 $vis struct $name(String);
16
17 impl $name {
18 /// Create a new instance
19 pub fn new<T: Into<String>>(value: T) -> Self {
20 Self(value.into())
21 }
22
23 /// Get the inner string value as a reference
24 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27
28 /// Consume and get the inner string value
29 pub fn into_inner(self) -> String {
30 self.0
31 }
32 }
33
34 impl std::fmt::Display for $name {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "{}", self.0)
37 }
38 }
39
40 impl From<String> for $name {
41 fn from(s: String) -> Self {
42 Self::new(s)
43 }
44 }
45
46 impl From<&str> for $name {
47 fn from(s: &str) -> Self {
48 Self::new(s)
49 }
50 }
51
52 impl AsRef<str> for $name {
53 fn as_ref(&self) -> &str {
54 &self.0
55 }
56 }
57
58 impl std::ops::Deref for $name {
59 type Target = str;
60 fn deref(&self) -> &Self::Target {
61 &self.0
62 }
63 }
64 };
65}
66
67/// Macro for creating validated NewTypes with prefix checking
68macro_rules! newtype_id {
69 ($(#[$attr:meta])* $vis:vis $name:ident, $prefix:expr) => {
70 $(#[$attr])*
71 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
72 $vis struct $name(String);
73
74 impl $name {
75 /// The expected prefix for this ID type
76 pub const PREFIX: &'static str = $prefix;
77
78 /// Create a new ID, validating the prefix
79 pub fn new<T: Into<String>>(value: T) -> Result<Self, InvalidIdError> {
80 let s = value.into();
81 if !s.starts_with(Self::PREFIX) {
82 return Err(InvalidIdError {
83 type_name: stringify!($name),
84 expected_prefix: Self::PREFIX,
85 actual_value: s,
86 });
87 }
88 Ok(Self(s))
89 }
90
91 /// Create without validation (for deserialization from trusted API)
92 pub(crate) fn new_unchecked<T: Into<String>>(value: T) -> Self {
93 Self(value.into())
94 }
95
96 /// Get the inner string value as a reference
97 pub fn as_str(&self) -> &str {
98 &self.0
99 }
100
101 /// Consume and get the inner string value
102 pub fn into_inner(self) -> String {
103 self.0
104 }
105 }
106
107 impl std::fmt::Display for $name {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 write!(f, "{}", self.0)
110 }
111 }
112
113 impl AsRef<str> for $name {
114 fn as_ref(&self) -> &str {
115 &self.0
116 }
117 }
118
119 impl std::ops::Deref for $name {
120 type Target = str;
121 fn deref(&self) -> &Self::Target {
122 &self.0
123 }
124 }
125
126 // Custom serde implementation to use new_unchecked during deserialization
127 impl Serialize for $name {
128 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
129 where
130 S: Serializer,
131 {
132 serializer.serialize_str(&self.0)
133 }
134 }
135
136 impl<'de> Deserialize<'de> for $name {
137 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
138 where
139 D: Deserializer<'de>,
140 {
141 let s = String::deserialize(deserializer)?;
142 Ok(Self::new_unchecked(s))
143 }
144 }
145 };
146}
147
148// ============================================================================
149// Error Types
150// ============================================================================
151
152/// Error when an ID doesn't have the expected prefix
153#[derive(Debug, Clone, thiserror::Error)]
154#[error("Invalid {type_name}: expected prefix '{expected_prefix}', got '{actual_value}'")]
155pub struct InvalidIdError {
156 /// The name of the ID type (e.g., "AccountId", "TransactionId")
157 pub type_name: &'static str,
158 /// The expected prefix for this ID type (e.g., "acc_", "trans_")
159 pub expected_prefix: &'static str,
160 /// The actual value that was provided
161 pub actual_value: String,
162}
163
164/// Error when an email address is invalid
165#[derive(Debug, Clone, thiserror::Error)]
166#[error("Invalid email address: '{0}'")]
167pub struct InvalidEmailError(pub String);
168
169// ============================================================================
170// Authentication & Authorization Types
171// ============================================================================
172
173newtype_string!(
174 /// User access token obtained through OAuth.
175 ///
176 /// This token is used to authenticate requests on behalf of a specific user.
177 /// It grants access to the user's connected accounts and transaction data.
178 pub UserToken
179);
180
181newtype_string!(
182 /// Application ID token for authenticating your app with Akahu.
183 ///
184 /// This is your app's identifier, used in the `X-Akahu-Id` header.
185 pub AppToken
186);
187
188newtype_string!(
189 /// Application secret for app-scoped endpoints.
190 ///
191 /// Used in combination with the app token for HTTP Basic Authentication
192 /// when accessing app-scoped endpoints like Categories and Connections.
193 ///
194 /// **Note**: Not available for Personal Apps.
195 pub AppSecret
196);
197
198newtype_string!(
199 /// OAuth client secret used during the token exchange flow.
200 ///
201 /// This may be the same as `AppSecret` depending on your app configuration.
202 pub ClientSecret
203);
204
205newtype_string!(
206 /// OAuth authorization code (short-lived, valid for ~60 seconds).
207 ///
208 /// Received from the OAuth flow and must be exchanged for a user access token
209 /// within 60 seconds.
210 pub AuthCode
211);
212
213newtype_string!(
214 /// OAuth redirect URI used during the authorization flow.
215 ///
216 /// Must match exactly the URI configured in your Akahu app settings.
217 pub RedirectUri
218);
219
220// ============================================================================
221// Resource Identifiers with Validation
222// ============================================================================
223
224newtype_id!(
225 /// Account identifier (always prefixed with `acc_`).
226 ///
227 /// Uniquely identifies a connected bank account within the Akahu system.
228 pub AccountId,
229 "acc_"
230);
231
232newtype_id!(
233 /// Transaction identifier (always prefixed with `trans_`).
234 ///
235 /// Uniquely identifies a transaction within the Akahu system.
236 pub TransactionId,
237 "trans_"
238);
239
240newtype_id!(
241 /// User identifier (always prefixed with `user_`).
242 ///
243 /// Uniquely identifies a user who has authorized your application.
244 pub UserId,
245 "user_"
246);
247
248newtype_id!(
249 /// Transfer identifier (always prefixed with `transfer_`).
250 ///
251 /// Uniquely identifies a transfer between the user's accounts.
252 pub TransferId,
253 "transfer_"
254);
255
256newtype_id!(
257 /// Payment identifier.
258 ///
259 /// Uniquely identifies a payment initiated through Akahu.
260 pub PaymentId,
261 "payment_"
262);
263
264newtype_id!(
265 /// Connection identifier (always prefixed with `conn_`).
266 ///
267 /// Uniquely identifies a financial institution connection.
268 pub ConnectionId,
269 "conn_"
270);
271
272newtype_id!(
273 /// Category identifier (always prefixed with `cat_`).
274 ///
275 /// Uniquely identifies an NZFCC category.
276 pub CategoryId,
277 "cat_"
278);
279
280newtype_id!(
281 /// Merchant identifier (always prefixed with `_merchant`).
282 ///
283 /// Uniquely identifies a merchant in the Akahu enrichment system.
284 pub MerchantId,
285 "_merchant"
286);
287
288newtype_id!(
289 /// Authorization identifier (always prefixed with `auth_`).
290 ///
291 /// Uniquely identifies an OAuth authorization.
292 pub AuthorizationId,
293 "auth_"
294);
295
296// ============================================================================
297// Pagination & Query Types
298// ============================================================================
299
300newtype_string!(
301 /// Pagination cursor token.
302 ///
303 /// Opaque token used to fetch the next page of results.
304 /// Obtained from the `cursor.next` field in paginated responses.
305 pub Cursor
306);
307
308// ============================================================================
309// Tests
310// ============================================================================
311
312#[cfg(test)]
313#[allow(
314 clippy::unwrap_used,
315 reason = "Tests need to unwrap to verify correctness"
316)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_account_id_validation() {
322 // Valid account ID
323 AccountId::new("acc_123456").unwrap();
324
325 // Invalid prefix
326 AccountId::new("trans_123456").unwrap_err();
327 AccountId::new("123456").unwrap_err();
328 }
329
330 #[test]
331 fn test_transaction_id_validation() {
332 // Valid transaction ID
333 TransactionId::new("trans_abcdef123").unwrap();
334
335 // Invalid prefix
336 TransactionId::new("acc_123456").unwrap_err();
337 }
338
339 #[test]
340 fn test_newtype_conversions() {
341 let token = UserToken::new("test_token");
342 assert_eq!(token.as_str(), "test_token");
343 assert_eq!(&*token, "test_token"); // Via Deref
344 assert_eq!(token.to_string(), "test_token");
345
346 let token2: UserToken = "another_token".into();
347 assert_eq!(token2.as_str(), "another_token");
348 }
349}