date filters strongly typed; formatting

This commit is contained in:
Aaron Manning 2025-02-13 15:27:44 +11:00
parent c8b91f66db
commit 9eeee510a5
11 changed files with 873 additions and 447 deletions

View File

@ -13,3 +13,4 @@ url = "2.2.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
urlencoding = "2.1.3" urlencoding = "2.1.3"
chrono = "0.4.39"

View File

@ -37,9 +37,11 @@ pub struct AccountResourceLinks {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct ResponseLinks { pub struct ResponseLinks {
/// The link to the previous page in the results. If this value is `None` there is no previous page. /// The link to the previous page in the results. If this value is `None`
/// there is no previous page.
pub prev: Option<String>, pub prev: Option<String>,
/// The link to the next page in the results. If this value is `None` there is no next page. /// The link to the next page in the results. If this value is `None` there
/// is no next page.
pub next: Option<String>, pub next: Option<String>,
} }
@ -64,11 +66,14 @@ pub struct TransactionLinks {
pub struct Attributes { pub struct Attributes {
/// The name associated with the account in the Up application. /// The name associated with the account in the Up application.
pub display_name: String, pub display_name: String,
/// The bank account type of this account. Possible values: SAVER, TRANSACTIONAL /// The bank account type of this account. Possible values: SAVER,
/// TRANSACTIONAL
pub account_type: AccountType, pub account_type: AccountType,
/// The ownership structure for this account. Possible values: INDIVIDUAL, JOINT /// The ownership structure for this account. Possible values: INDIVIDUAL,
/// JOINT
pub ownership_type: OwnershipType, pub ownership_type: OwnershipType,
/// The available balance of the account, taking into account any amounts that are currently on hold. /// The available balance of the account, taking into account any amounts
/// that are currently on hold.
pub balance: standard::MoneyObject, pub balance: standard::MoneyObject,
/// The date-time at which this account was first opened. /// The date-time at which this account was first opened.
pub created_at: String, pub created_at: String,
@ -94,9 +99,11 @@ pub enum OwnershipType {
pub struct ListAccountsOptions { pub struct ListAccountsOptions {
/// The number of records to return in each page. /// The number of records to return in each page.
page_size: Option<u8>, page_size: Option<u8>,
/// The type of account for which to return records. This can be used to filter Savers from spending accounts. /// The type of account for which to return records. This can be used to
/// filter Savers from spending accounts.
filter_account_type: Option<String>, filter_account_type: Option<String>,
/// The account ownership structure for which to return records. This can be used to filter 2Up accounts from Up accounts. /// The account ownership structure for which to return records. This can
/// be used to filter 2Up accounts from Up accounts.
filter_ownership_type: Option<String>, filter_ownership_type: Option<String>,
} }
@ -130,14 +137,18 @@ impl ListAccountsOptions {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[accountType]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[accountType]={}", urlencoding::encode(value))
);
} }
if let Some(value) = &self.filter_ownership_type { if let Some(value) = &self.filter_ownership_type {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[ownershipType]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[ownershipType]={}", urlencoding::encode(value))
);
} }
if !query.is_empty() { if !query.is_empty() {
@ -147,9 +158,16 @@ impl ListAccountsOptions {
} }
impl Client { impl Client {
/// Retrieve a paginated list of all accounts for the currently authenticated user. The returned list is paginated and can be scrolled by following the `prev` and `next` links where present. /// Retrieve a paginated list of all accounts for the currently
pub async fn list_accounts(&self, options : &ListAccountsOptions) -> Result<ListAccountsResponse, error::Error> { /// authenticated user. The returned list is paginated and can be scrolled
let mut url = reqwest::Url::parse(&format!("{}/accounts", BASE_URL)).map_err(error::Error::UrlParse)?; /// by following the `prev` and `next` links where present.
pub async fn list_accounts(
&self,
options: &ListAccountsOptions,
) -> Result<ListAccountsResponse, error::Error> {
let mut url = reqwest::Url::parse(
&format!("{}/accounts", BASE_URL)
).map_err(error::Error::UrlParse)?;
options.add_params(&mut url); options.add_params(&mut url);
let res = reqwest::Client::new() let res = reqwest::Client::new()
@ -162,13 +180,15 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let account_response : ListAccountsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let account_response: ListAccountsResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(account_response) Ok(account_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let error: error::ErrorResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
@ -176,14 +196,20 @@ impl Client {
} }
/// Retrieve a specific account by providing its unique identifier. /// Retrieve a specific account by providing its unique identifier.
pub async fn get_account(&self, id : &str) -> Result<GetAccountResponse, error::Error> { pub async fn get_account(
// This assertion is because without an ID the request is thought to be a request for &self,
// many accounts, and therefore the error messages are very unclear. id: &str,
) -> Result<GetAccountResponse, error::Error> {
// This assertion is because without an ID the request is thought to be
// a request for many accounts, and therefore the error messages are
// very unclear.
if id.is_empty() { if id.is_empty() {
panic!("The provided account ID must not be empty."); panic!("The provided account ID must not be empty.");
} }
let url = reqwest::Url::parse(&format!("{}/accounts/{}", BASE_URL, id)).map_err(error::Error::UrlParse)?; let url = reqwest::Url::parse(
&format!("{}/accounts/{}", BASE_URL, id)
).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.get(url) .get(url)
@ -195,13 +221,15 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let account_response : GetAccountResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let account_response: GetAccountResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(account_response) Ok(account_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let error: error::ErrorResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }

View File

@ -21,7 +21,8 @@ pub struct GetCategoryResponse {
pub struct CategoryResource { pub struct CategoryResource {
/// The type of this resource: categories /// The type of this resource: categories
pub r#type: String, pub r#type: String,
/// The unique identifier for this category. This is a human-readable but URL-safe value. /// The unique identifier for this category. This is a human-readable but
/// URL-safe value.
pub id: String, pub id: String,
pub attributes: Attributes, pub attributes: Attributes,
pub relationships: Relationships, pub relationships: Relationships,
@ -91,7 +92,8 @@ pub struct CategoryResourceLinks {
#[derive(Default)] #[derive(Default)]
pub struct ListCategoriesOptions { pub struct ListCategoriesOptions {
/// The unique identifier of a parent category for which to return only its children. /// The unique identifier of a parent category for which to return only its
/// children.
filter_parent: Option<String>, filter_parent: Option<String>,
} }
@ -108,7 +110,9 @@ impl ListCategoriesOptions {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[parent]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[parent]={}", urlencoding::encode(value))
);
} }
if !query.is_empty() { if !query.is_empty() {
@ -121,7 +125,8 @@ impl ListCategoriesOptions {
#[derive(Serialize)] #[derive(Serialize)]
struct CategoriseTransactionRequest { struct CategoriseTransactionRequest {
/// The category to set on the transaction. Set this entire key to `null` de-categorize a transaction. /// The category to set on the transaction. Set this entire key to `null`
/// de-categorize a transaction.
data: Option<CategoryInputResourceIdentifier>, data: Option<CategoryInputResourceIdentifier>,
} }
@ -129,14 +134,21 @@ struct CategoriseTransactionRequest {
struct CategoryInputResourceIdentifier { struct CategoryInputResourceIdentifier {
/// The type of this resource: `categories` /// The type of this resource: `categories`
r#type: String, r#type: String,
/// The unique identifier of the category, as returned by the `list_categories` method. /// The unique identifier of the category, as returned by the
/// `list_categories` method.
id: String, id: String,
} }
impl Client { impl Client {
/// Retrieve a list of all categories and their ancestry. The returned list is not paginated. /// Retrieve a list of all categories and their ancestry. The returned list
pub async fn list_categories(&self, options : &ListCategoriesOptions) -> Result<ListCategoriesResponse, error::Error> { /// is not paginated.
let mut url = reqwest::Url::parse(&format!("{}/categories", BASE_URL)).map_err(error::Error::UrlParse)?; pub async fn list_categories(
&self,
options: &ListCategoriesOptions,
) -> Result<ListCategoriesResponse, error::Error> {
let mut url = reqwest::Url::parse(
&format!("{}/categories", BASE_URL)
).map_err(error::Error::UrlParse)?;
options.add_params(&mut url); options.add_params(&mut url);
let res = reqwest::Client::new() let res = reqwest::Client::new()
@ -149,13 +161,15 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let category_response : ListCategoriesResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let category_response: ListCategoriesResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(category_response) Ok(category_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let error: error::ErrorResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
@ -163,14 +177,19 @@ impl Client {
} }
/// Retrieve a specific category by providing its unique identifier. /// Retrieve a specific category by providing its unique identifier.
pub async fn get_category(&self, id : &str) -> Result<GetCategoryResponse, error::Error> { pub async fn get_category(
// This assertion is because without an ID the request is thought to be a request for &self, id: &str,
// many categories, and therefore the error messages are very unclear. ) -> Result<GetCategoryResponse, error::Error> {
// This assertion is because without an ID the request is thought to be
// a request for many categories, and therefore the error messages are
// very unclear.
if id.is_empty() { if id.is_empty() {
panic!("The provided category ID must not be empty."); panic!("The provided category ID must not be empty.");
} }
let url = reqwest::Url::parse(&format!("{}/categories/{}", BASE_URL, id)).map_err(error::Error::UrlParse)?; let url = reqwest::Url::parse(
&format!("{}/categories/{}", BASE_URL, id)
).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.get(url) .get(url)
@ -182,22 +201,39 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let category_response : GetCategoryResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let category_response: GetCategoryResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(category_response ) Ok(category_response )
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let error: error::ErrorResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Updates the category associated with a transaction. Only transactions for which `is_categorizable` is set to true support this operation. The `id` is taken from the list exposed on `list_categories` and cannot be one of the top-level (parent) categories. To de-categorize a transaction, set the entire `data` key to `null`. The associated category, along with its request URL is also exposed via the category relationship on the transaction resource returned from `get_transaction`. /// Updates the category associated with a transaction. Only transactions
pub async fn categorise_transaction(&self, transaction_id : &str, category : Option<&str>) -> Result<(), error::Error> { /// for which `is_categorizable` is set to true support this operation. The
let url = reqwest::Url::parse(&format!("{}/transactions/{}/relationships/category", BASE_URL, transaction_id)).map_err(error::Error::UrlParse)?; /// `id` is taken from the list exposed on `list_categories` and cannot be
/// one of the top-level (parent) categories. To de-categorize a
/// transaction, set the entire `data` key to `null`. The associated
/// category, along with its request URL is also exposed via the category
/// relationship on the transaction resource returned from `get_transaction`.
pub async fn categorise_transaction(
&self,
transaction_id: &str,
category: Option<&str>,
) -> Result<(), error::Error> {
let url = reqwest::Url::parse(
&format!("{}/transactions/{}/relationships/category",
BASE_URL,
transaction_id,
)
).map_err(error::Error::UrlParse)?;
let category = category.map(|id| { let category = category.map(|id| {
CategoryInputResourceIdentifier { CategoryInputResourceIdentifier {
@ -207,7 +243,9 @@ impl Client {
}); });
let body = CategoriseTransactionRequest { data: category }; let body = CategoriseTransactionRequest { data: category };
let body = serde_json::to_string(&body).map_err(error::Error::Serialize)?; let body =
serde_json::to_string(&body)
.map_err(error::Error::Serialize)?;
println!("{}", body); println!("{}", body);
@ -226,7 +264,8 @@ impl Client {
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let error: error::ErrorResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }

View File

@ -11,26 +11,44 @@ pub enum Error {
Request(reqwest::Error), Request(reqwest::Error),
/// Represents errors from the API (i.e. a non `2XX` response code). /// Represents errors from the API (i.e. a non `2XX` response code).
Api(ErrorResponse), Api(ErrorResponse),
/// Represents an error in deserializing JSON to the required structures. Occurances of this /// Represents an error in deserializing JSON to the required structures.
/// error should be treated as a bug in the library. /// Occurences of this error should be treated as a bug in the library.
Json(serde_json::Error), Json(serde_json::Error),
/// Represents an error in reading the body from the HTTP response. Occurances of this /// Represents an error in reading the body from the HTTP response.
/// error should be treated as a bug in the library. /// Occurences of this error should be treated as a bug in the library.
BodyRead(reqwest::Error), BodyRead(reqwest::Error),
/// Represents an error serializing the data to be sent to the API. Occurances of this /// Represents an error serializing the data to be sent to the API.
/// error should be treated as a bug in the library. /// Occurences of this error should be treated as a bug in the library.
Serialize(serde_json::Error), Serialize(serde_json::Error),
} }
impl fmt::Display for Error { impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self { match self {
Self::UrlParse(val) => write!(f, "Failed to parse the URL before making the request: {:?}", val), Self::UrlParse(val) => write!(f,
Self::Request(val) => write!(f, "Failed to make the HTTP request to the API endpoint: {:?}", val), "Failed to parse the URL before making the request: {:?}",
Self::Api(val) => write!(f, "The API returned an error response: {:?}", val), val,
Self::Json(val) => write!(f, "Failed to deserialize the returned JSON to the correct format: {:?}", val), ),
Self::BodyRead(val) => write!(f, "Failed to read the response body as a UTF-8 string: {:?}", val), Self::Request(val) => write!(f,
Self::Serialize(val) => write!(f, "Failed to serialize the request data: {:?}", val), "Failed to make the HTTP request to the API endpoint: {:?}",
val,
),
Self::Api(val) => write!(f,
"The API returned an error response: {:?}",
val,
),
Self::Json(val) => write!(f,
"Failed to deserialize the returned JSON to the correct format: {:?}",
val,
),
Self::BodyRead(val) => write!(f,
"Failed to read the response body as a UTF-8 string: {:?}",
val,
),
Self::Serialize(val) => write!(f,
"Failed to serialize the request data: {:?}",
val,
),
} }
} }
} }
@ -45,13 +63,20 @@ pub struct ErrorResponse {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct ErrorObject { pub struct ErrorObject {
/// The HTTP status code associated with this error. The status indicates the broad type of error according to HTTP semantics. /// The HTTP status code associated with this error. The status indicates
/// the broad type of error according to HTTP semantics.
pub status: String, pub status: String,
/// A short description of this error. This should be stable across multiple occurrences of this type of error and typically expands on the reason for the status code. /// A short description of this error. This should be stable across
/// multiple occurrences of this type of error and typically expands on the
/// reason for the status code.
pub title: String, pub title: String,
/// A detailed description of this error. This should be considered unique to individual occurrences of an error and subject to change. It is useful for debugging purposes. /// A detailed description of this error. This should be considered unique
/// to individual occurrences of an error and subject to change. It is
/// useful for debugging purposes.
pub detail: String, pub detail: String,
/// If applicable, location in the request that this error relates to. This may be a parameter in the query string, or a an attribute in the request body. /// If applicable, location in the request that this error relates to. This
/// may be a parameter in the query string, or a an attribute in the
/// request body.
pub source: Option<Source>, pub source: Option<Source>,
} }
@ -59,6 +84,7 @@ pub struct ErrorObject {
pub struct Source { pub struct Source {
/// If this error relates to a query parameter, the name of the parameter. /// If this error relates to a query parameter, the name of the parameter.
pub parameter: Option<String>, pub parameter: Option<String>,
/// If this error relates to an attribute in the request body, a rfc-6901 JSON pointer to the attribute. /// If this error relates to an attribute in the request body, a rfc-6901
/// JSON pointer to the attribute.
pub pointer: Option<String> pub pointer: Option<String>
} }

View File

@ -1,7 +1,10 @@
macro_rules! implement_pagination_v1 { macro_rules! implement_pagination_v1 {
($t:ty) => { ($t:ty) => {
impl $t { impl $t {
async fn follow_link(client : &Client, url : &str) -> Result<Self, error::Error> { async fn follow_link(
client: &Client,
url: &str,
) -> Result<Self, error::Error> {
let res = reqwest::Client::new() let res = reqwest::Client::new()
.get(url) .get(url)
.header("Authorization", client.auth_header()) .header("Authorization", client.auth_header())
@ -11,22 +14,36 @@ macro_rules! implement_pagination_v1 {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let response : Self = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let response: Self =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(response) Ok(response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Follows the link to the next page, returns None of the next page does not exist. /// Follows the link to the next page, returns None of the next
pub async fn next(&self, client : &Client) -> Option<Result<Self, error::Error>> { /// page does not exist.
pub async fn next(
&self,
client: &Client,
) -> Option<Result<Self, error::Error>> {
match match
self self
.links .links
@ -39,8 +56,11 @@ macro_rules! implement_pagination_v1 {
} }
} }
/// Follows the link to the previous page, returns None of the previous page does not exist. /// Follows the link to the previous page, returns None of the
pub async fn prev(&self, client : &Client) -> Option<Result<Self, error::Error>> { /// previous page does not exist.
pub async fn prev(
&self, client: &Client,
) -> Option<Result<Self, error::Error>> {
match match
self self
.links .links

View File

@ -3,17 +3,23 @@ mod macros;
/// Error types and trait implementations. /// Error types and trait implementations.
pub mod error; pub mod error;
/// Types for modelling and interacting with [accounts](https://developer.up.com.au/#accounts). /// Types for modelling and interacting with
/// [accounts](https://developer.up.com.au/#accounts).
pub mod accounts; pub mod accounts;
/// Types for modelling and interacting with [categories](https://developer.up.com.au/#categories). /// Types for modelling and interacting with
/// [categories](https://developer.up.com.au/#categories).
pub mod categories; pub mod categories;
/// Types for modelling and interacting with [tags](https://developer.up.com.au/#tags). /// Types for modelling and interacting with
/// [tags](https://developer.up.com.au/#tags).
pub mod tags; pub mod tags;
/// Types for modelling and interacting with [transactions](https://developer.up.com.au/#transactions). /// Types for modelling and interacting with
/// [transactions](https://developer.up.com.au/#transactions).
pub mod transactions; pub mod transactions;
/// Types for modelling and interacting with [utilities](https://developer.up.com.au/#utility_endpoints). /// Types for modelling and interacting with
/// [utilities](https://developer.up.com.au/#utility_endpoints).
pub mod utilities; pub mod utilities;
/// Types for modelling and interacting with [webhooks](https://developer.up.com.au/#webhooks). /// Types for modelling and interacting with
/// [webhooks](https://developer.up.com.au/#webhooks).
pub mod webhooks; pub mod webhooks;
/// Types which are stardized (and named) across many resources. /// Types which are stardized (and named) across many resources.
pub mod standard; pub mod standard;
@ -27,7 +33,8 @@ pub struct Client {
} }
impl Client { impl Client {
/// Creates an instance of the `Client` from the access token. Visit [this page](https://api.up.com.au/getting_started) to get such a token. /// Creates an instance of the `Client` from the access token. Visit
/// [this page](https://api.up.com.au/getting_started) to get such a token.
pub fn new(access_token: String) -> Self { pub fn new(access_token: String) -> Self {
Client { Client {
access_token access_token

View File

@ -12,9 +12,13 @@ pub enum AccountTypeEnum {
pub struct MoneyObject { pub struct MoneyObject {
/// The ISO 4217 currency code. /// The ISO 4217 currency code.
pub currency_code: String, pub currency_code: String,
/// The amount of money, formatted as a string in the relevant currency. For example, for an Australian dollar value of $10.56, this field will be `"10.56"`. The currency symbol is not included in the string /// The amount of money, formatted as a string in the relevant currency.
/// For example, for an Australian dollar value of $10.56, this field will
/// be `"10.56"`. The currency symbol is not included in the string
pub value: String, pub value: String,
/// The amount of money in the smallest denomination for the currency, as a 64-bit integer. For example, for an Australian dollar value of $10.56, this field will be `1056`. /// The amount of money in the smallest denomination for the currency, as a
/// 64-bit integer. For example, for an Australian dollar value of $10.56,
/// this field will be `1056`.
pub value_in_base_units: i64, pub value_in_base_units: i64,
} }
@ -35,18 +39,24 @@ pub enum TransactionStatusEnum {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HoldInfoObject { pub struct HoldInfoObject {
/// The amount of this transaction while in the `HELD` status, in Australian dollars. /// The amount of this transaction while in the `HELD` status, in
/// Australian dollars.
pub amount: MoneyObject, pub amount: MoneyObject,
/// The foreign currency amount of this transaction while in the `HELD` status. This field will be `null` for domestic transactions. The amount was converted to the AUD amount reflected in the `amount` field. /// The foreign currency amount of this transaction while in the `HELD`
/// status. This field will be `null` for domestic transactions. The amount
/// was converted to the AUD amount reflected in the `amount` field.
pub foreign_amount: Option<MoneyObject>, pub foreign_amount: Option<MoneyObject>,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RoundUpObject { pub struct RoundUpObject {
/// The total amount of this Round Up, including any boosts, represented as a negative value. /// The total amount of this Round Up, including any boosts, represented as
/// a negative value.
pub amount: MoneyObject, pub amount: MoneyObject,
/// The portion of the Round Up `amount` owing to boosted Round Ups, represented as a negative value. If no boost was added to the Round Up this field will be `null`. /// The portion of the Round Up `amount` owing to boosted Round Ups,
/// represented as a negative value. If no boost was added to the Round Up
/// this field will be `null`.
pub boost_portion: Option<MoneyObject>, pub boost_portion: Option<MoneyObject>,
} }

View File

@ -89,9 +89,18 @@ struct TagRequest {
} }
impl Client { impl Client {
/// Retrieve a list of all tags currently in use. The returned list is paginated and can be scrolled by following the `next` and `prev` links where present. Results are ordered lexicographically. The transactions relationship for each tag exposes a link to get the transactions with the given tag. /// Retrieve a list of all tags currently in use. The returned list is
pub async fn list_tags(&self, options : &ListTagsOptions) -> Result<ListTagsResponse, error::Error> { /// paginated and can be scrolled by following the `next` and `prev` links
let mut url = reqwest::Url::parse(&format!("{}/tags", BASE_URL)).map_err(error::Error::UrlParse)?; /// where present. Results are ordered lexicographically. The transactions
/// relationship for each tag exposes a link to get the transactions with
/// the given tag.
pub async fn list_tags(
&self,
options: &ListTagsOptions,
) -> Result<ListTagsResponse, error::Error> {
let mut url = reqwest::Url::parse(
&format!("{}/tags", BASE_URL)
).map_err(error::Error::UrlParse)?;
options.add_params(&mut url); options.add_params(&mut url);
let res = reqwest::Client::new() let res = reqwest::Client::new()
@ -103,23 +112,46 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let tags_response : ListTagsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let tags_response: ListTagsResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(tags_response) Ok(tags_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Associates one or more tags with a specific transaction. No more than 6 tags may be present on any single transaction. Duplicate tags are silently ignored. The associated tags, along with this request URL, are also exposed via the tags relationship on the transaction resource returned from `get_transaction`. /// Associates one or more tags with a specific transaction. No more than 6
pub async fn add_tags(&self, transaction_id : &str, tags : Vec<String>) -> Result<(), error::Error> { /// tags may be present on any single transaction. Duplicate tags are
let url = reqwest::Url::parse(&format!("{}/transactions/{}/relationships/tags", BASE_URL, transaction_id)).map_err(error::Error::UrlParse)?; /// silently ignored. The associated tags, along with this request URL, are
/// also exposed via the tags relationship on the transaction resource
/// returned from `get_transaction`.
pub async fn add_tags(
&self,
transaction_id: &str,
tags: Vec<String>,
) -> Result<(), error::Error> {
let url = reqwest::Url::parse(
&format!("{}/transactions/{}/relationships/tags",
BASE_URL,
transaction_id,
)
).map_err(error::Error::UrlParse)?;
let tags = let tags =
tags tags
@ -131,7 +163,9 @@ impl Client {
.collect(); .collect();
let body = TagRequest { data: tags }; let body = TagRequest { data: tags };
let body = serde_json::to_string(&body).map_err(error::Error::Serialize)?; let body =
serde_json::to_string(&body)
.map_err(error::Error::Serialize)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.post(url) .post(url)
@ -147,17 +181,34 @@ impl Client {
Ok(()) Ok(())
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Disassociates one or more tags from a specific transaction. Tags that are not associated are silently ignored. The associated tags, along with this request URL, are also exposed via the tags relationship on the transaction resource returned from `get_transaction`. /// Disassociates one or more tags from a specific transaction. Tags that
pub async fn delete_tags(&self, transaction_id : &str, tags : Vec<String>) -> Result<(), error::Error> { /// are not associated are silently ignored. The associated tags, along
let url = reqwest::Url::parse(&format!("{}/transactions/{}/relationships/tags", BASE_URL, transaction_id)).map_err(error::Error::UrlParse)?; /// with this request URL, are also exposed via the tags relationship on
/// the transaction resource returned from `get_transaction`.
pub async fn delete_tags(
&self,
transaction_id: &str,
tags: Vec<String>,
) -> Result<(), error::Error> {
let url = reqwest::Url::parse(
&format!("{}/transactions/{}/relationships/tags",
BASE_URL,
transaction_id,
)
).map_err(error::Error::UrlParse)?;
let tags = let tags =
tags tags
@ -169,7 +220,9 @@ impl Client {
.collect(); .collect();
let body = TagRequest { data: tags }; let body = TagRequest { data: tags };
let body = serde_json::to_string(&body).map_err(error::Error::Serialize)?; let body =
serde_json::to_string(&body)
.map_err(error::Error::Serialize)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.delete(url) .delete(url)
@ -185,8 +238,13 @@ impl Client {
Ok(()) Ok(())
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }

View File

@ -39,7 +39,9 @@ pub struct TransactionResourceLinks {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Relationships { pub struct Relationships {
pub account: Account, pub account: Account,
/// If this transaction is a transfer between accounts, this relationship will contain the account the transaction went to/came from. The `amount` field can be used to determine the direction of the transfer. /// If this transaction is a transfer between accounts, this relationship
/// will contain the account the transaction went to/came from. The `amount`
/// field can be used to determine the direction of the transfer.
pub transfer_account: TransferAccount, pub transfer_account: TransferAccount,
pub category: Category, pub category: Category,
pub parent_category: ParentCategory, pub parent_category: ParentCategory,
@ -102,7 +104,8 @@ pub struct CategoryData {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct CategoryLinks { pub struct CategoryLinks {
/// The link to retrieve or modify linkage between this resources and the related resource(s) in this relationship. /// The link to retrieve or modify linkage between this resources and the
/// related resource(s) in this relationship.
#[serde(rename = "self")] #[serde(rename = "self")]
pub this: String, pub this: String,
pub related: Option<String>, pub related: Option<String>,
@ -144,7 +147,8 @@ pub struct TagsData {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct TagsLinks { pub struct TagsLinks {
/// The link to retrieve or modify linkage between this resources and the related resource(s) in this relationship. /// The link to retrieve or modify linkage between this resources and the
/// related resource(s) in this relationship.
#[serde(rename = "self")] #[serde(rename = "self")]
pub this: String, pub this: String,
} }
@ -152,29 +156,47 @@ pub struct TagsLinks {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Attributes { pub struct Attributes {
/// The current processing status of this transaction, according to whether or not this transaction has settled or is still held. Possible values: `HELD`, `SETTLED` /// The current processing status of this transaction, according to whether
/// or not this transaction has settled or is still held. Possible values:
/// `HELD`, `SETTLED`
pub status: standard::TransactionStatusEnum, pub status: standard::TransactionStatusEnum,
/// The original, unprocessed text of the transaction. This is often not a perfect indicator of the actual merchant, but it is useful for reconciliation purposes in some cases. /// The original, unprocessed text of the transaction. This is often not a
/// perfect indicator of the actual merchant, but it is useful for
/// reconciliation purposes in some cases.
pub raw_text: Option<String>, pub raw_text: Option<String>,
/// A short description for this transaction. Usually the merchant name for purchases. /// A short description for this transaction. Usually the merchant name for
/// purchases.
pub description: String, pub description: String,
/// Attached message for this transaction, such as a payment message, or a transfer note. /// Attached message for this transaction, such as a payment message, or a
/// transfer note.
pub message: Option<String>, pub message: Option<String>,
/// Boolean flag set to true on transactions that support the use of categories. /// Boolean flag set to true on transactions that support the use of
/// categories.
pub is_categorizable: bool, pub is_categorizable: bool,
/// If this transaction is currently in the `HELD` status, or was ever in the `HELD` status, the `amount` and `foreignAmount` of the transaction while `HELD`. /// If this transaction is currently in the `HELD` status, or was ever in
/// the `HELD` status, the `amount` and `foreignAmount` of the transaction
/// while `HELD`.
pub hold_info: Option<standard::HoldInfoObject>, pub hold_info: Option<standard::HoldInfoObject>,
/// Details of how this transaction was rounded-up. If no Round Up was applied this field will be `null`. /// Details of how this transaction was rounded-up. If no Round Up was
/// applied this field will be `null`.
pub round_up: Option<standard::RoundUpObject>, pub round_up: Option<standard::RoundUpObject>,
/// If all or part of this transaction was instantly reimbursed in the form of cashback, details of the reimbursement. /// If all or part of this transaction was instantly reimbursed in the form
/// of cashback, details of the reimbursement.
pub cashback: Option<standard::CashBackObject>, pub cashback: Option<standard::CashBackObject>,
/// The amount of this transaction in Australian dollars. For transactions that were once `HELD` but are now `SETTLED`, refer to the `holdInfo` field for the original `amount` the transaction was `HELD` at. /// The amount of this transaction in Australian dollars. For transactions
/// that were once `HELD` but are now `SETTLED`, refer to the `holdInfo`
/// field for the original `amount` the transaction was `HELD` at.
pub amount: standard::MoneyObject, pub amount: standard::MoneyObject,
/// The foreign currency amount of this transaction. This field will be `null` for domestic transactions. The amount was converted to the AUD amount reflected in the `amount` of this transaction. Refer to the `holdInfo` field for the original foreignAmount the transaction was `HELD` at. /// The foreign currency amount of this transaction. This field will be
/// `null` for domestic transactions. The amount was converted to the AUD
/// amount reflected in the `amount` of this transaction. Refer to the
/// `holdInfo` field for the original foreignAmount the transaction was
/// `HELD` at.
pub foreign_amount: Option<standard::MoneyObject>, pub foreign_amount: Option<standard::MoneyObject>,
/// Information about the card used for this transaction, if applicable. /// Information about the card used for this transaction, if applicable.
pub card_purchase_method: Option<standard::CardPurchaseMethodObject>, pub card_purchase_method: Option<standard::CardPurchaseMethodObject>,
/// The date-time at which this transaction settled. This field will be `null` for transactions that are currently in the `HELD` status. /// The date-time at which this transaction settled. This field will be
/// `null` for transactions that are currently in the `HELD` status.
pub settled_at: Option<String>, pub settled_at: Option<String>,
/// The date-time at which this transaction was first encountered. /// The date-time at which this transaction was first encountered.
pub created_at: String, pub created_at: String,
@ -182,31 +204,50 @@ pub struct Attributes {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct ResponseLinks { pub struct ResponseLinks {
/// The link to the previous page in the results. If this value is null there is no previous page. /// The link to the previous page in the results. If this value is null
/// there is no previous page.
pub prev: Option<String>, pub prev: Option<String>,
/// The link to the next page in the results. If this value is null there is no next page. /// The link to the next page in the results. If this value is null there is
/// no next page.
pub next: Option<String>, pub next: Option<String>,
} }
// ----------------- Input Objects ----------------- // ----------------- Input Objects -----------------
#[derive(Default)] pub struct ListTransactionsOptions<Tz: chrono::TimeZone> {
pub struct ListTransactionsOptions {
/// The number of records to return in each page. /// The number of records to return in each page.
page_size: Option<u8>, page_size: Option<u8>,
/// The transaction status for which to return records. This can be used to filter `HELD` transactions from those that are `SETTLED`. /// The transaction status for which to return records. This can be used to
/// filter `HELD` transactions from those that are `SETTLED`.
filter_status: Option<String>, filter_status: Option<String>,
/// The start date-time from which to return records, formatted according to rfc-3339. Not to be used for pagination purposes. /// The start date-time from which to return records, formatted according to
filter_since : Option<String>, /// rfc-3339. Not to be used for pagination purposes.
/// The end date-time up to which to return records, formatted according to rfc-3339. Not to be used for pagination purposes. filter_since: Option<chrono::DateTime<Tz>>,
filter_until : Option<String>, /// The end date-time up to which to return records, formatted according to
/// The category identifier for which to filter transactions. Both parent and child categories can be filtered through this parameter. /// rfc-3339. Not to be used for pagination purposes.
filter_until: Option<chrono::DateTime<Tz>>,
/// The category identifier for which to filter transactions. Both parent
/// and child categories can be filtered through this parameter.
filter_category: Option<String>, filter_category: Option<String>,
/// A transaction tag to filter for which to return records. If the tag does not exist, zero records are returned and a success response is given. /// A transaction tag to filter for which to return records. If the tag does
/// not exist, zero records are returned and a success response is given.
filter_tag: Option<String>, filter_tag: Option<String>,
} }
impl ListTransactionsOptions { impl<Tz: chrono::TimeZone> Default for ListTransactionsOptions<Tz> {
fn default() -> Self {
Self {
page_size: None,
filter_status: None,
filter_since: None,
filter_until: None,
filter_category: None,
filter_tag: None,
}
}
}
impl<Tz: chrono::TimeZone> ListTransactionsOptions<Tz> {
/// Sets the page size. /// Sets the page size.
pub fn page_size(&mut self, value: u8) { pub fn page_size(&mut self, value: u8) {
self.page_size = Some(value); self.page_size = Some(value);
@ -218,12 +259,12 @@ impl ListTransactionsOptions {
} }
/// Sets the since filter value. /// Sets the since filter value.
pub fn filter_since(&mut self, value : String) { pub fn filter_since(&mut self, value: chrono::DateTime<Tz>) {
self.filter_since = Some(value); self.filter_since = Some(value);
} }
/// Sets the until filter value. /// Sets the until filter value.
pub fn filter_until (&mut self, value : String) { pub fn filter_until (&mut self, value: chrono::DateTime<Tz>) {
self.filter_until = Some(value); self.filter_until = Some(value);
} }
@ -251,35 +292,45 @@ impl ListTransactionsOptions {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[status]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[status]={}", urlencoding::encode(value))
);
} }
if let Some(value) = &self.filter_since { if let Some(value) = &self.filter_since {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[since]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[since]={}", urlencoding::encode(&value.to_rfc3339()))
);
} }
if let Some(value) = &self.filter_until { if let Some(value) = &self.filter_until {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[until]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[until]={}", urlencoding::encode(&value.to_rfc3339()))
);
} }
if let Some(value) = &self.filter_category { if let Some(value) = &self.filter_category {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[category]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[category]={}", urlencoding::encode(value))
);
} }
if let Some(value) = &self.filter_tag { if let Some(value) = &self.filter_tag {
if !query.is_empty() { if !query.is_empty() {
query.push('&'); query.push('&');
} }
query.push_str(&format!("filter[tag]={}", urlencoding::encode(value))); query.push_str(
&format!("filter[tag]={}", urlencoding::encode(value))
);
} }
if !query.is_empty() { if !query.is_empty() {
@ -289,9 +340,20 @@ impl ListTransactionsOptions {
} }
impl Client { impl Client {
/// Retrieve a list of all transactions across all accounts for the currently authenticated user. The returned list is paginated and can be scrolled by following the `next` and `prev` links where present. To narrow the results to a specific date range pass one or both of `filter[since]` and `filter[until]` in the query string. These filter parameters should not be used for pagination. Results are ordered newest first to oldest last. /// Retrieve a list of all transactions across all accounts for the
pub async fn list_transactions(&self, options : &ListTransactionsOptions) -> Result<ListTransactionsResponse, error::Error> { /// currently authenticated user. The returned list is paginated and can be
let mut url = reqwest::Url::parse(&format!("{}/transactions", BASE_URL)).map_err(error::Error::UrlParse)?; /// scrolled by following the `next` and `prev` links where present. To
/// narrow the results to a specific date range pass one or both of
/// `filter[since]` and `filter[until]` in the query string. These filter
/// parameters should not be used for pagination. Results are ordered newest
/// first to oldest last.
pub async fn list_transactions<Tz: chrono::TimeZone>(
&self,
options: &ListTransactionsOptions<Tz>,
) -> Result<ListTransactionsResponse, error::Error> {
let mut url = reqwest::Url::parse(
&format!("{}/transactions", BASE_URL)
).map_err(error::Error::UrlParse)?;
options.add_params(&mut url); options.add_params(&mut url);
let res = reqwest::Client::new() let res = reqwest::Client::new()
@ -303,14 +365,24 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let transaction_response : ListTransactionsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let transaction_response: ListTransactionsResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(transaction_response) Ok(transaction_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
@ -318,14 +390,20 @@ impl Client {
} }
/// Retrieve a specific transaction by providing its unique identifier. /// Retrieve a specific transaction by providing its unique identifier.
pub async fn get_transaction(&self, id : &String) -> Result<GetTransactionResponse, error::Error> { pub async fn get_transaction(
// This assertion is because without an ID the request is thought to be a request for &self,
// many transactions, and therefore the error messages are very unclear. id: &String,
) -> Result<GetTransactionResponse, error::Error> {
// This assertion is because without an ID the request is thought to be
// a request for many transactions, and therefore the error messages
// are very unclear.
if id.is_empty() { if id.is_empty() {
panic!("The provided transaction ID must not be empty."); panic!("The provided transaction ID must not be empty.");
} }
let url = reqwest::Url::parse(&format!("{}/transactions/{}", BASE_URL, id)).map_err(error::Error::UrlParse)?; let url = reqwest::Url::parse(
&format!("{}/transactions/{}", BASE_URL, id)
).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.get(url) .get(url)
@ -337,22 +415,35 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let transaction_response : GetTransactionResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let transaction_response: GetTransactionResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(transaction_response) Ok(transaction_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let error: error::ErrorResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Retrieve a list of all transactions for a specific account. The returned list is paginated and can be scrolled by following the `next` and `prev` links where present. To narrow the results to a specific date range pass one or both of `filter[since]` and `filter[until]` in the query string. These filter parameters should not be used for pagination. Results are ordered newest first to oldest last. /// Retrieve a list of all transactions for a specific account. The returned
pub async fn list_transactions_by_account(&self, account_id : &String, options : &ListTransactionsOptions) -> Result<ListTransactionsResponse, error::Error> { /// list is paginated and can be scrolled by following the `next` and `prev`
let mut url = reqwest::Url::parse(&format!("{}/accounts/{}/transactions", BASE_URL, account_id)).map_err(error::Error::UrlParse)?; /// links where present. To narrow the results to a specific date range pass
/// one or both of `filter[since]` and `filter[until]` in the query string.
/// These filter parameters should not be used for pagination. Results are
/// ordered newest first to oldest last.
pub async fn list_transactions_by_account<Tz: chrono::TimeZone>(
&self,
account_id: &String,
options: &ListTransactionsOptions<Tz>,
) -> Result<ListTransactionsResponse, error::Error> {
let mut url = reqwest::Url::parse(
&format!("{}/accounts/{}/transactions", BASE_URL, account_id)
).map_err(error::Error::UrlParse)?;
options.add_params(&mut url); options.add_params(&mut url);
let res = reqwest::Client::new() let res = reqwest::Client::new()
@ -365,13 +456,15 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let transaction_response : ListTransactionsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let transaction_response: ListTransactionsResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(transaction_response) Ok(transaction_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body = res.text().await.map_err(error::Error::BodyRead)?;
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let error: error::ErrorResponse =
serde_json::from_str(&body).map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }

View File

@ -19,9 +19,12 @@ pub struct Meta {
} }
impl Client { impl Client {
/// Make a basic ping request to the API. This is useful to verify that authentication is functioning correctly. /// Make a basic ping request to the API. This is useful to verify that
/// authentication is functioning correctly.
pub async fn ping(&self) -> Result<PingResponse, error::Error> { pub async fn ping(&self) -> Result<PingResponse, error::Error> {
let url = reqwest::Url::parse(&format!("{}/util/ping", BASE_URL)).map_err(error::Error::UrlParse)?; let url = reqwest::Url::parse(
&format!("{}/util/ping", BASE_URL)
).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.get(url) .get(url)
@ -32,15 +35,25 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
res.text()
.await
.map_err(error::Error::BodyRead)?;
println!("{}", body); println!("{}", body);
let ping_response : PingResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; let ping_response: PingResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(ping_response) Ok(ping_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }

View File

@ -39,10 +39,19 @@ pub struct WebhookResource {
pub struct Attributes { pub struct Attributes {
/// The URL that this webhook is configured to `POST` events to. /// The URL that this webhook is configured to `POST` events to.
pub url: String, pub url: String,
/// An optional description that was provided at the time the webhook was created. /// An optional description that was provided at the time the webhook was
/// created.
pub description: Option<String>, pub description: Option<String>,
/// A shared secret key used to sign all webhook events sent to the configured webhook URL. This field is returned only once, upon the initial creation of the webhook. If lost, create a new webhook and delete this webhook. /// A shared secret key used to sign all webhook events sent to the
/// The webhook URL receives a request with a `X-Up-Authenticity-Signature` header, which is the SHA-256 HMAC of the entire raw request body signed using this `secretKey`. It is advised to compute and check this signature to verify the authenticity of requests sent to the webhook URL. See Handling webhook events for full details. /// configured webhook URL. This field is returned only once, upon the
/// initial creation of the webhook. If lost, create a new webhook and
/// delete this webhook.
///
/// The webhook URL receives a request with a `X-Up-Authenticity-Signature`
/// header, which is the SHA-256 HMAC of the entire raw request body signed
/// using this `secretKey`. It is advised to compute and check this
/// signature to verify the authenticity of requests sent to the webhook
/// URL. See Handling webhook events for full details.
pub secret_key: Option<String>, pub secret_key: Option<String>,
/// The date-time at which this webhook was created. /// The date-time at which this webhook was created.
pub created_at: String, pub created_at: String,
@ -73,9 +82,11 @@ pub struct WebhookResourceLinks {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct ResponseLinks { pub struct ResponseLinks {
/// The link to the previous page in the results. If this value is `None` there is no previous page. /// The link to the previous page in the results. If this value is `None`
/// there is no previous page.
pub prev: Option<String>, pub prev: Option<String>,
/// The link to the next page in the results. If this value is `None` there is no next page. /// The link to the next page in the results. If this value is `None` there
/// is no next page.
pub next: Option<String>, pub next: Option<String>,
} }
@ -89,7 +100,8 @@ pub struct PingWebhookResponse {
pub struct WebhookEventResource { pub struct WebhookEventResource {
/// The type of this resource: `webhook-events` /// The type of this resource: `webhook-events`
pub r#type: String, pub r#type: String,
/// The unique identifier for this event. This will remain constant across delivery retries. /// The unique identifier for this event. This will remain constant across
/// delivery retries.
pub id: String, pub id: String,
pub attributes: EventAttributes, pub attributes: EventAttributes,
pub relationships: EventRelationships, pub relationships: EventRelationships,
@ -144,7 +156,8 @@ pub struct TransactionLinks {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EventAttributes { pub struct EventAttributes {
/// The type of this event. This can be used to determine what action to take in response to the event. /// The type of this event. This can be used to determine what action to
/// take in response to the event.
pub event_type: standard::WebhookEventTypeEnum, pub event_type: standard::WebhookEventTypeEnum,
/// The date-time at which this event was generated. /// The date-time at which this event was generated.
pub created_at: String, pub created_at: String,
@ -217,9 +230,11 @@ pub struct Response {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct LogsResponseLinks { pub struct LogsResponseLinks {
/// The link to the previous page in the results. If this value is `None` there is no previous page. /// The link to the previous page in the results. If this value is `None`
/// there is no previous page.
pub prev: Option<String>, pub prev: Option<String>,
/// The link to the next page in the results. If this value is `None` there is no next page. /// The link to the next page in the results. If this value is `None` there
/// is no next page.
pub next: Option<String>, pub next: Option<String>,
} }
@ -297,16 +312,24 @@ pub struct WebhookInputResource {
#[derive(Serialize)] #[derive(Serialize)]
pub struct InputAttributes { pub struct InputAttributes {
/// The URL that this webhook should post events to. This must be a valid HTTP or HTTPS URL that does not exceed 300 characters in length. /// The URL that this webhook should post events to. This must be a valid
/// HTTP or HTTPS URL that does not exceed 300 characters in length.
pub url: String, pub url: String,
/// An optional description for this webhook, up to 64 characters in length. /// An optional description for this webhook, up to 64 characters in length.
pub description: Option<String>, pub description: Option<String>,
} }
impl Client { impl Client {
/// Retrieve a list of configured webhooks. The returned list is paginated and can be scrolled by following the `next` and `prev` links where present. Results are ordered oldest first to newest last. /// Retrieve a list of configured webhooks. The returned list is paginated
pub async fn list_webhooks(&self, options : &ListWebhooksOptions) -> Result<ListWebhooksResponse, error::Error> { /// and can be scrolled by following the `next` and `prev` links where
let mut url = reqwest::Url::parse(&format!("{}/webhooks", BASE_URL)).map_err(error::Error::UrlParse)?; /// present. Results are ordered oldest first to newest last.
pub async fn list_webhooks(
&self,
options: &ListWebhooksOptions,
) -> Result<ListWebhooksResponse, error::Error> {
let mut url = reqwest::Url::parse(
&format!("{}/webhooks", BASE_URL)
).map_err(error::Error::UrlParse)?;
options.add_params(&mut url); options.add_params(&mut url);
let res = reqwest::Client::new() let res = reqwest::Client::new()
@ -318,14 +341,24 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let webhook_response : ListWebhooksResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let webhook_response: ListWebhooksResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(webhook_response) Ok(webhook_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
@ -333,14 +366,20 @@ impl Client {
} }
/// Retrieve a specific webhook by providing its unique identifier. /// Retrieve a specific webhook by providing its unique identifier.
pub async fn get_webhook(&self, id : &str) -> Result<GetWebhookResponse, error::Error> { pub async fn get_webhook(
// This assertion is because without an ID the request is thought to be a request for &self,
// many webhooks, and therefore the error messages are very unclear. id: &str,
) -> Result<GetWebhookResponse, error::Error> {
// This assertion is because without an ID the request is thought to be
// a request for many webhooks, and therefore the error messages are
// very unclear.
if id.is_empty() { if id.is_empty() {
panic!("The provided webhook ID must not be empty."); panic!("The provided webhook ID must not be empty.");
} }
let url = reqwest::Url::parse(&format!("{}/webhooks/{}", BASE_URL, id)).map_err(error::Error::UrlParse)?; let url = reqwest::Url::parse(
&format!("{}/webhooks/{}", BASE_URL, id)
).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.get(url) .get(url)
@ -351,34 +390,70 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let webhook_response : GetWebhookResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let webhook_response: GetWebhookResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(webhook_response) Ok(webhook_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Create a new webhook with a given URL. The URL will receive webhook events as JSON-encoded `POST` requests. The URL must respond with a HTTP `200` status on success. /// Create a new webhook with a given URL. The URL will receive webhook
/// There is currently a limit of 10 webhooks at any given time. Once this limit is reached, existing webhooks will need to be deleted before new webhooks can be created. /// events as JSON-encoded `POST` requests. The URL must respond with a
/// Event delivery is retried with exponential backoff if the URL is unreachable or it does not respond with a `200` status. The response includes a `secretKey` attribute, which is used to sign requests sent to the webhook URL. It will not be returned from any other endpoints within the Up API. If the `secretKey` is lost, simply create a new webhook with the same URL, capture its `secretKey` and then delete the original webhook. See Handling webhook events for details on how to process webhook events. /// HTTP `200` status on success.
/// It is probably a good idea to test the webhook by sending it a `PING` event after creating it. ///
pub async fn create_webhook(&self, webhook_url : &str, description : Option<String>) -> Result<CreateWebhookResponse, error::Error> { /// There is currently a limit of 10 webhooks at any given time. Once this
let url = reqwest::Url::parse(&format!("{}/webhooks", BASE_URL)).map_err(error::Error::UrlParse)?; /// limit is reached, existing webhooks will need to be deleted before new
/// webhooks can be created.
///
/// Event delivery is retried with exponential backoff if the URL is
/// unreachable or it does not respond with a `200` status. The response
/// includes a `secretKey` attribute, which is used to sign requests sent
/// to the webhook URL. It will not be returned from any other endpoints
/// within the Up API. If the `secretKey` is lost, simply create a new
/// webhook with the same URL, capture its `secretKey` and then delete the
/// original webhook. See Handling webhook events for details on how to
/// process webhook events.
///
/// It is probably a good idea to test the webhook by sending it a `PING`
/// event after creating it.
pub async fn create_webhook(
&self,
webhook_url: &str,
description: Option<String>,
) -> Result<CreateWebhookResponse, error::Error> {
let url = reqwest::Url::parse(
&format!("{}/webhooks", BASE_URL)
).map_err(error::Error::UrlParse)?;
let body = CreateWebhookRequest { let body = CreateWebhookRequest {
data: WebhookInputResource { data: WebhookInputResource {
attributes : InputAttributes { url : String::from(webhook_url), description } attributes: InputAttributes {
url: String::from(webhook_url),
description,
}
} }
}; };
let body = serde_json::to_string(&body).map_err(error::Error::Serialize)?; let body =
serde_json::to_string(&body)
.map_err(error::Error::Serialize)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.post(url) .post(url)
@ -391,23 +466,36 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::CREATED => { reqwest::StatusCode::CREATED => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let webhook_response : CreateWebhookResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let webhook_response: CreateWebhookResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(webhook_response) Ok(webhook_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Delete a specific webhook by providing its unique identifier. Once deleted, webhook events will no longer be sent to the configured URL. /// Delete a specific webhook by providing its unique identifier. Once
/// deleted, webhook events will no longer be sent to the configured URL.
pub async fn delete_webhook(&self, id: &str) -> Result<(), error::Error> { pub async fn delete_webhook(&self, id: &str) -> Result<(), error::Error> {
let url = reqwest::Url::parse(&format!("{}/webhooks/{}", BASE_URL, id)).map_err(error::Error::UrlParse)?; let url = reqwest::Url::parse(
&format!("{}/webhooks/{}", BASE_URL, id)
).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.delete(url) .delete(url)
@ -421,17 +509,30 @@ impl Client {
Ok(()) Ok(())
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Send a `PING` event to a webhook by providing its unique identifier. This is useful for testing and debugging purposes. The event is delivered asynchronously and its data is returned in the response to this request. /// Send a `PING` event to a webhook by providing its unique identifier.
pub async fn ping_webhook(&self, id : &str) -> Result<PingWebhookResponse, error::Error> { /// This is useful for testing and debugging purposes. The event is
let url = reqwest::Url::parse(&format!("{}/webhooks/{}/ping", BASE_URL, id)).map_err(error::Error::UrlParse)?; /// delivered asynchronously and its data is returned in the response to
/// this request.
pub async fn ping_webhook(
&self,
id: &str,
) -> Result<PingWebhookResponse, error::Error> {
let url = reqwest::Url::parse(
&format!("{}/webhooks/{}/ping", BASE_URL, id)
).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new() let res = reqwest::Client::new()
.post(url) .post(url)
@ -444,23 +545,43 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::CREATED => { reqwest::StatusCode::CREATED => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let webhook_response : PingWebhookResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let webhook_response: PingWebhookResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(webhook_response) Ok(webhook_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }
} }
} }
/// Retrieve a list of delivery logs for a webhook by providing its unique identifier. This is useful for analysis and debugging purposes. The returned list is paginated and can be scrolled by following the `next` and `prev` links where present. Results are ordered newest first to oldest last. Logs may be automatically purged after a period of time. /// Retrieve a list of delivery logs for a webhook by providing its unique
pub async fn list_webhook_logs(&self, id : &str, options : &ListWebhookLogsOptions) -> Result<ListWebhookLogsResponse, error::Error> { /// identifier. This is useful for analysis and debugging purposes. The
let mut url = reqwest::Url::parse(&format!("{}/webhooks/{}/logs", BASE_URL, id)).map_err(error::Error::UrlParse)?; /// returned list is paginated and can be scrolled by following the `next`
/// and `prev` links where present. Results are ordered newest first to
/// oldest last. Logs may be automatically purged after a period of time.
pub async fn list_webhook_logs(
&self,
id: &str,
options: &ListWebhookLogsOptions,
) -> Result<ListWebhookLogsResponse, error::Error> {
let mut url = reqwest::Url::parse(
&format!("{}/webhooks/{}/logs", BASE_URL, id)
).map_err(error::Error::UrlParse)?;
options.add_params(&mut url); options.add_params(&mut url);
let res = reqwest::Client::new() let res = reqwest::Client::new()
@ -472,14 +593,24 @@ impl Client {
match res.status() { match res.status() {
reqwest::StatusCode::OK => { reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let webhook_response : ListWebhookLogsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let webhook_response: ListWebhookLogsResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Ok(webhook_response) Ok(webhook_response)
}, },
_ => { _ => {
let body = res.text().await.map_err(error::Error::BodyRead)?; let body =
let error : error::ErrorResponse = serde_json::from_str(&body).map_err(error::Error::Json)?; res.text()
.await
.map_err(error::Error::BodyRead)?;
let error: error::ErrorResponse =
serde_json::from_str(&body)
.map_err(error::Error::Json)?;
Err(error::Error::Api(error)) Err(error::Error::Api(error))
} }