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}