first commit

This commit is contained in:
aaron-jack-manning 2022-07-09 23:01:31 +10:00
commit ca2c349e4a
11 changed files with 572 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
/Cargo.lock

10
Cargo.toml Normal file
View File

@ -0,0 +1,10 @@
[package]
name = "up-api"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = "0.11.11"
url = "2.2.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# Up API
A convenient and easy to use wrapper for the [UP Bank API](https://developer.up.com.au).
## Example
The following example shows the calculation of the sum of all transactions after a given date.
```
use up_api::Client;
use up_api::transactions::ListTransactionsOptions;
#[tokio::main]
async fn main() {
let token = std::env::var("UP_ACCESS_TOKEN").unwrap();
let client = Client::new(token.to_string());
let mut options = ListTransactionsOptions::default();
options.filter_since("2020-01-01T01:02:03+10:00".to_string());
let transactions = client.list_transactions().unwrap();
let total : f32 =
transactions
.data
.into_iter()
.map(|t| t.attributes.amount.value)
.map(|v| v.parse::<f32>())
.sum();
println!("{}", total);
}
```

194
src/accounts.rs Normal file
View File

@ -0,0 +1,194 @@
use crate::{Client, error};
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct GetAccountResponse {
/// The account returned in this response.
pub data : Data,
}
#[derive(Deserialize, Debug)]
pub struct ListAccountsResponse {
/// The list of accounts returned in this response.
pub data : Vec<Data>,
pub links : ResponseLinks,
}
#[derive(Deserialize, Debug)]
pub struct DataLinks {
#[serde(rename = "self")]
/// The canonical link to this resource within the API.
pub this : Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct ResponseLinks {
/// The link to the previous page in the results. If this value is `None` there is no previous page.
pub prev : Option<String>,
/// The link to the next page in the results. If this value is `None` there is no next page.
pub next : Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Data {
/// The type of this resource: `accounts`.
pub r#type : String,
/// The unique identifier for this account.
pub id : String,
pub attributes : Attributes,
pub relationships : Relationships,
pub links : Option<DataLinks>,
}
#[derive(Deserialize, Debug)]
pub struct Relationships {
pub transactions : Transactions,
}
#[derive(Deserialize, Debug)]
pub struct Transactions {
pub links : Option<TransactionLinks>,
}
#[derive(Deserialize, Debug)]
pub struct TransactionLinks {
/// The link to retrieve the related resource(s) in this relationship.
pub related : String,
}
#[derive(Deserialize, Debug)]
pub struct Attributes {
#[serde(rename = "displayName")]
/// The name associated with the account in the Up application.
pub display_name : String,
#[serde(rename = "accountType")]
/// The bank account type of this account. Possible values: SAVER, TRANSACTIONAL
pub account_type : String,
#[serde(rename = "ownershipType")]
/// The ownership structure for this account. Possible values: INDIVIDUAL, JOINT
pub ownership_type : String,
/// The available balance of the account, taking into account any amounts that are currently on hold.
pub balance : Balance,
#[serde(rename = "createdAt")]
/// The date-time at which this account was first opened.
pub created_at : String,
}
#[derive(Deserialize, Debug)]
pub struct Balance {
#[serde(rename = "currencyCode")]
/// The ISO 4217 currency code.
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
pub value : String,
#[serde(rename = "valueInBaseUnits")]
/// 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,
}
#[derive(Default)]
pub struct ListAccountsOptions {
/// The number of records to return in each page.
page_size : Option<u8>,
/// The type of account for which to return records. This can be used to filter Savers from spending accounts.
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.
filter_owership_type : Option<String>,
}
impl ListAccountsOptions {
/// Sets the page size.
pub fn page_size(&mut self, value : u8) {
self.page_size = Some(value);
}
/// Sets the account type filter value.
pub fn filter_account_type(&mut self, value : String) {
self.filter_account_type = Some(value);
}
/// Sets the ownership type filter value.
pub fn filter_owership_type(&mut self, value : String) {
self.filter_owership_type = Some(value);
}
fn add_params(&self, url : &mut reqwest::Url) {
if let Some(value) = &self.page_size {
url.set_query(Some(&format!("page[size]={}", value)));
}
if let Some(value) = &self.filter_account_type {
url.set_query(Some(&format!("filter[accountType]={}", value)));
}
if let Some(value) = &self.filter_owership_type {
url.set_query(Some(&format!("filter[ownershipType]={}", value)));
}
}
}
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.
pub async fn list_accounts(&self, options : &ListAccountsOptions) -> Result<ListAccountsResponse, error::Error> {
let mut url = reqwest::Url::parse(&format!("{}/accounts", crate::BASE_URL)).map_err(error::Error::UrlParse)?;
options.add_params(&mut url);
let res = reqwest::Client::new()
.get(url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?;
println!("{}", body);
let account_response : ListAccountsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(account_response)
},
_ => {
let body = 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))
}
}
}
/// Retrieve a specific account by providing its unique identifier.
pub async fn get_account(&self, 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() {
panic!("The provided account ID must not be empty.");
}
let url = reqwest::Url::parse(&format!("{}/accounts/{}", crate::BASE_URL, id)).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new()
.get(url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?;
println!("{}", body);
let account_response : GetAccountResponse = serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(account_response)
},
_ => {
let body = 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))
}
}
}
}

0
src/categories.rs Normal file
View File

64
src/error.rs Normal file
View File

@ -0,0 +1,64 @@
use std::fmt;
use serde::Deserialize;
#[derive(Debug)]
/// Primary error type for requests made through `up_api::Client`.
pub enum Error {
/// Represents cases where the URL could not be parsed correctly.
UrlParse(url::ParseError),
/// Represents an error in making the HTTP request.
Request(reqwest::Error),
/// Represents errors from the API (i.e. a non `2XX` response code).
Api(ErrorResponse),
/// Represents an error in deserializing JSON to the required structures. Occurances of this
/// error should be treated as a bug in the library.
Json(serde_json::Error),
/// Represents an error in reading the body from the HTTP response. Occurances of this
/// error should be treated as a bug in the library.
BodyRead(reqwest::Error),
/// Represents an error serializing the data to be sent to the API. Occurances of this
/// error should be treated as a bug in the library.
Serialize(serde_json::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f : &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
match self {
Self::UrlParse(val) => write!(f, "Failed to parse the URL before making the request: {:?}", val),
Self::Request(val) => write!(f, "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),
}
}
}
impl std::error::Error for Error {}
#[derive(Deserialize, Debug)]
pub struct ErrorResponse {
/// The list of errors returned in this response.
pub errors : Vec<UpError>,
}
#[derive(Deserialize, Debug)]
pub struct UpError {
/// The HTTP status code associated with this error. The status indicates the broad type of error according to HTTP semantics.
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.
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.
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.
pub source : Option<Source>,
}
#[derive(Deserialize, Debug)]
pub struct Source {
/// If this error relates to a query parameter, the name of the parameter.
pub parameter : Option<String>,
/// If this error relates to an attribute in the request body, a rfc-6901 JSON pointer to the attribute.
pub pointer : Option<String>
}

40
src/lib.rs Normal file
View File

@ -0,0 +1,40 @@
//! # Up API
//!
//! A convenient and easy to use wrapper for the [UP Bank API](https://developer.up.com.au).
// Include examples so that this exactly matches the README.
/// Error types and trait implementations.
pub mod error;
/// Types for modelling and interacting with [accounts](https://developer.up.com.au/#accounts).
pub mod accounts;
/// INCOMPLETE: Types for modelling and interacting with [categories](https://developer.up.com.au/#categories).
pub mod categories;
/// Types for modelling and interacting with [tags](https://developer.up.com.au/#tags).
pub mod tags;
/// INCOMPLETE: Types for modelling and interacting with [transactions](https://developer.up.com.au/#transactions).
pub mod transactions;
/// Types for modelling and interacting with [utilities](https://developer.up.com.au/#utility_endpoints).
pub mod utilities;
/// INCOMPLETE: Types for modelling and interacting with [webhooks](https://developer.up.com.au/#webhooks).
pub mod webhooks;
static BASE_URL : &str = "https://api.up.com.au/api/v1";
/// A client for interacting with the Up API.
pub struct Client {
access_token : String,
}
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.
pub fn new(access_token : String) -> Self {
Client {
access_token
}
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.access_token)
}
}

182
src/tags.rs Normal file
View File

@ -0,0 +1,182 @@
use crate::{Client, error};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Debug)]
pub struct ListTagsResponse {
/// The list of tags returned in this response.
pub data : Vec<Data>,
pub links : ResponseLinks,
}
#[derive(Deserialize, Debug)]
pub struct Data {
/// The type of this resource: `tags`
pub r#type : String,
/// The label of the tag, which also acts as the tags unique identifier.
pub id : String,
pub relationships : Relationships,
}
#[derive(Deserialize, Debug)]
pub struct Relationships {
pub transactions : Transactions,
}
#[derive(Deserialize, Debug)]
pub struct Transactions {
pub links : Option<TransactionLinks>,
}
#[derive(Deserialize, Debug)]
pub struct TransactionLinks {
/// The link to retrieve the related resource(s) in this relationship.
pub related : String,
}
#[derive(Deserialize, Debug)]
pub struct ResponseLinks {
pub prev : Option<String>,
pub next : Option<String>,
}
#[derive(Default)]
pub struct ListTagsOptions {
/// The number of records to return in each page.
page_size : Option<u8>,
}
impl ListTagsOptions {
/// Sets the page size.
pub fn page_size(&mut self, value : u8) {
self.page_size = Some(value);
}
fn add_params(&self, url : &mut reqwest::Url) {
if let Some(value) = &self.page_size {
url.set_query(Some(&format!("page[size]={}", value)));
}
}
}
#[derive(Serialize)]
struct TagInputResourceIdentifier {
/// The type of this resource: `tags`
r#type : String,
/// The label of the tag, which also acts as the tags unique identifier.
id : String,
}
#[derive(Serialize)]
struct TagRequest {
/// The tags to add to or remove from the transaction.
data : Vec<TagInputResourceIdentifier>
}
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.
pub async fn list_tags(&self, options : &ListTagsOptions) -> Result<ListTagsResponse, error::Error> {
let mut url = reqwest::Url::parse(&format!("{}/tags", crate::BASE_URL)).map_err(error::Error::UrlParse)?;
options.add_params(&mut url);
let res = reqwest::Client::new()
.get(url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?;
println!("{}", body);
let tags_response : ListTagsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(tags_response)
},
_ => {
let body = 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))
}
}
}
/// 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`.
pub async fn add_tags(&self, transaction_id : &str, tags : Vec<String>) -> Result<(), error::Error> {
let url = reqwest::Url::parse(&format!("{}/transactions/{}/relationships/tags", crate::BASE_URL, transaction_id)).map_err(error::Error::UrlParse)?;
let tags =
tags
.into_iter()
.map(|t| TagInputResourceIdentifier {
r#type : String::from("tags"),
id : t
})
.collect();
let body = TagRequest { data : tags };
let body = serde_json::to_string(&body).map_err(error::Error::Serialize)?;
let res = reqwest::Client::new()
.post(url)
.header("Authorization", self.auth_header())
.body(body)
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::NO_CONTENT => {
Ok(())
},
_ => {
let body = 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))
}
}
}
/// 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`.
pub async fn delete_tags(&self, transaction_id : &str, tags : Vec<String>) -> Result<(), error::Error> {
let url = reqwest::Url::parse(&format!("{}/transactions/{}/relationships/tags", crate::BASE_URL, transaction_id)).map_err(error::Error::UrlParse)?;
let tags =
tags
.into_iter()
.map(|t| TagInputResourceIdentifier {
r#type : String::from("tags"),
id : t
})
.collect();
let body = TagRequest { data : tags };
let body = serde_json::to_string(&body).map_err(error::Error::Serialize)?;
let res = reqwest::Client::new()
.delete(url)
.header("Authorization", self.auth_header())
.body(body)
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::NO_CONTENT => {
Ok(())
},
_ => {
let body = 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))
}
}
}
}

0
src/transactions.rs Normal file
View File

47
src/utilities.rs Normal file
View File

@ -0,0 +1,47 @@
use crate::{Client, error};
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct PingResponse {
pub meta : Meta,
}
#[derive(Deserialize, Debug)]
pub struct Meta {
/// The unique identifier of the authenticated customer.
pub id : String,
#[serde(rename = "statusEmoji")]
/// A cute emoji that represents the response status.
pub status_emoji : String,
}
impl Client {
/// 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> {
let url = reqwest::Url::parse(&format!("{}/util/ping", crate::BASE_URL)).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new()
.get(url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::OK => {
let body = res.text().await.map_err(error::Error::BodyRead)?;
println!("{}", body);
let ping_response : PingResponse = serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(ping_response)
},
_ => {
let body = 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))
}
}
}
}

0
src/webhooks.rs Normal file
View File