0.1.2 webhooks; other minor changes to messages and comments

This commit is contained in:
aaron-jack-manning 2022-07-15 13:23:01 +10:00
parent daf7242f10
commit 11de1e7256
7 changed files with 517 additions and 9 deletions

View File

@ -1,6 +1,6 @@
[package] [package]
name = "up-api" name = "up-api"
version = "0.1.1" version = "0.1.2"
edition = "2021" edition = "2021"
description = "A convenient and easy to use wrapper for the Up Bank API." description = "A convenient and easy to use wrapper for the Up Bank API."
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"

View File

@ -44,7 +44,3 @@ async fn main() {
println!("{}", total); println!("{}", total);
} }
``` ```
## Planned Features
- Currently this API wrapper supports all of the `v1` Up API endpoints except [webhooks](https://developer.up.com.au/#webhooks). This is planned for a (hopefully soon) future release.

View File

@ -117,7 +117,7 @@ impl ListCategoriesOptions {
} }
} }
// ----------------- Input Objects ----------------- // ----------------- Request Objects -----------------
#[derive(Serialize)] #[derive(Serialize)]
struct CategoriseTransactionRequest { struct CategoriseTransactionRequest {
@ -165,9 +165,9 @@ 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(&self, id : &str) -> Result<GetCategoryResponse, error::Error> {
// This assertion is because without an ID the request is thought to be a request for // 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. // many categories, 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 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)?;

View File

@ -13,6 +13,8 @@ pub mod tags;
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).
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;

View File

@ -80,3 +80,21 @@ pub struct CardPurchaseMethodObject {
/// The last four digits of the card used for the purchase, if applicable. /// The last four digits of the card used for the purchase, if applicable.
pub card_number_suffix : Option<String>, pub card_number_suffix : Option<String>,
} }
#[derive(Deserialize, Debug)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum WebhookEventTypeEnum {
TransactionCreated,
TransactionSettled,
TransactionDeleted,
Ping,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum WebhookDeliveryStatusEnum {
Delivered,
Undeliverable,
BadResponseCode,
}

View File

@ -320,7 +320,7 @@ 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(&self, id : &String) -> Result<GetTransactionResponse, error::Error> {
// This assertion is because without an ID the request is thought to be a request for // 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. // 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.");
} }

492
src/v1/webhooks.rs Normal file
View File

@ -0,0 +1,492 @@
use crate::v1::{Client, error, BASE_URL, standard};
use serde::{Deserialize, Serialize};
// ----------------- Response Objects -----------------
#[derive(Deserialize, Debug)]
pub struct ListWebhooksResponse {
/// The list of webhooks returned in this response.
pub data : Vec<WebhookResource>,
pub links : ResponseLinks,
}
#[derive(Deserialize, Debug)]
pub struct GetWebhookResponse {
/// The webhook returned in the response.
pub data : WebhookResource,
}
#[derive(Deserialize, Debug)]
pub struct CreateWebhookResponse {
/// The webhook that was created.
pub data : WebhookResource,
}
#[derive(Deserialize, Debug)]
pub struct WebhookResource {
/// The type of this resource: `webhooks`
pub r#type : String,
/// The unique identifier for this webhook.
pub id : String,
pub attributes : Attributes,
pub relationships : Relationships,
pub links : WebhookResourceLinks,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Attributes {
/// The URL that this webhook is configured to `POST` events to.
pub url : String,
/// An optional description that was provided at the time the webhook was created.
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.
/// 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>,
/// The date-time at which this webhook was created.
pub created_at : String,
}
#[derive(Deserialize, Debug)]
pub struct Relationships {
pub logs : Logs,
}
#[derive(Deserialize, Debug)]
pub struct Logs {
pub links : Option<LogsLinks>,
}
#[derive(Deserialize, Debug)]
pub struct LogsLinks {
/// The link to retrieve the related resource(s) in this relationship.
pub related : String,
}
#[derive(Deserialize, Debug)]
pub struct WebhookResourceLinks {
/// The canonical link to this resource within the API.
#[serde(rename = "self")]
pub this : 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 PingWebhookResponse {
/// The webhook event data sent to the subscribed webhook.
pub data : WebhookEventResource,
}
#[derive(Deserialize, Debug)]
pub struct WebhookEventResource {
/// The type of this resource: `webhook-events`
pub r#type : String,
/// The unique identifier for this event. This will remain constant across delivery retries.
pub id : String,
pub attributes : EventAttributes,
pub relationships : EventRelationships,
}
#[derive(Deserialize, Debug)]
pub struct EventRelationships {
pub webhook : Webhook,
pub transaction : Option<Transaction>,
}
#[derive(Deserialize, Debug)]
pub struct Transaction {
pub data : TransactionData,
pub links : Option<TransactionLinks>,
}
#[derive(Deserialize, Debug)]
pub struct Webhook {
pub data : WebhookData,
pub links : Option<WebhookLinks>,
}
#[derive(Deserialize, Debug)]
pub struct WebhookData {
/// The type of this resource: `webhooks`
pub r#type : String,
/// The unique identifier of the resource within its type.
pub id : String,
}
#[derive(Deserialize, Debug)]
pub struct WebhookLinks {
/// The link to retrieve the related resource(s) in this relationship.
pub related : String,
}
#[derive(Deserialize, Debug)]
pub struct TransactionData {
/// The type of this resource: `transactions`
pub r#type : String,
/// The unique identifier of the resource within its type.
pub id : String,
}
#[derive(Deserialize, Debug)]
pub struct TransactionLinks {
/// The link to retrieve the related resource(s) in this relationship.
pub related : String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct EventAttributes {
/// 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,
/// The date-time at which this event was generated.
pub created_at : String,
}
#[derive(Deserialize, Debug)]
pub struct ListWebhookLogsResponse {
/// The list of delivery logs returned in this response.
pub data : Vec<WebhookDeliveryLogResource>,
pub links : LogsResponseLinks,
}
#[derive(Deserialize, Debug)]
pub struct WebhookDeliveryLogResource {
/// The type of this resource: `webhook-delivery-logs`
pub r#type : String,
/// The unique identifier for this log entry.
pub id : String,
pub attributes : DeliveryLogAttributes,
pub relationships : DeliveryLogRelationships,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DeliveryLogRelationships {
pub webhook_event : WebhookEvent,
}
#[derive(Deserialize, Debug)]
pub struct WebhookEvent {
pub data : WebhookEventData
}
#[derive(Deserialize, Debug)]
pub struct WebhookEventData {
/// The type of this resource: `webhook-events`
pub r#type : String,
/// The unique identifier of the resource within its type.
pub id : String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DeliveryLogAttributes {
/// Information about the request that was sent to the webhook URL.
pub request : Request,
/// Information about the response that was received from the webhook URL.
pub response : Option<Response>,
/// The success or failure status of this delivery attempt.
pub delivery_status : standard::WebhookDeliveryStatusEnum,
/// The date-time at which this log entry was created.
pub created_at : String,
}
#[derive(Deserialize, Debug)]
pub struct Request {
/// The payload that was sent in the request body.
pub body : String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Response {
/// The HTTP status code received in the response.
pub status_code : i64,
/// The payload that was received in the response body.
pub body : String,
}
#[derive(Deserialize, Debug)]
pub struct LogsResponseLinks {
/// 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>,
}
// ----------------- Input Objects -----------------
#[derive(Default)]
pub struct ListWebhooksOptions {
/// The number of records to return in each page.
page_size : Option<u8>,
}
impl ListWebhooksOptions {
/// 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) {
let mut query = String::new();
if let Some(value) = &self.page_size {
if !query.is_empty() {
query.push('&');
}
query.push_str(&format!("page[size]={}", value));
}
if !query.is_empty() {
url.set_query(Some(&query));
}
}
}
#[derive(Default)]
pub struct ListWebhookLogsOptions {
/// The number of records to return in each page.
page_size : Option<u8>,
}
impl ListWebhookLogsOptions {
/// 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) {
let mut query = String::new();
if let Some(value) = &self.page_size {
if !query.is_empty() {
query.push('&');
}
query.push_str(&format!("page[size]={}", value));
}
if !query.is_empty() {
url.set_query(Some(&query));
}
}
}
// ----------------- Request Objects -----------------
#[derive(Serialize)]
pub struct CreateWebhookRequest {
/// The webhook resource to create.
pub data : WebhookInputResource,
}
#[derive(Serialize)]
pub struct WebhookInputResource {
pub attributes : InputAttributes,
}
#[derive(Serialize)]
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.
pub url : String,
/// An optional description for this webhook, up to 64 characters in length.
pub description : Option<String>,
}
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.
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);
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)?;
let webhook_response : ListWebhooksResponse = serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(webhook_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 webhook by providing its unique identifier.
pub async fn get_webhook(&self, 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() {
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 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)?;
let webhook_response : GetWebhookResponse = serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(webhook_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))
}
}
}
/// 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.
/// 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.
/// 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 {
data : WebhookInputResource {
attributes : InputAttributes { url : String::from(webhook_url), description }
}
};
let body = serde_json::to_string(&body).map_err(error::Error::Serialize)?;
let res = reqwest::Client::new()
.post(url)
.header("Authorization", self.auth_header())
.header("Content-Type", "application/json")
.body(body)
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::CREATED => {
let body = 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)
},
_ => {
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))
}
}
}
/// 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> {
let url = reqwest::Url::parse(&format!("{}/webhooks/{}", BASE_URL, id)).map_err(error::Error::UrlParse)?;
let res = reqwest::Client::new()
.delete(url)
.header("Authorization", self.auth_header())
.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))
}
}
}
/// 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.
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()
.post(url)
.header("Authorization", self.auth_header())
.header("Content-Type", "application/json")
.header("Content-Length", "0")
.send()
.await
.map_err(error::Error::Request)?;
match res.status() {
reqwest::StatusCode::CREATED => {
let body = 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)
},
_ => {
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 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.
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);
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)?;
let webhook_response : ListWebhookLogsResponse = serde_json::from_str(&body).map_err(error::Error::Json)?;
Ok(webhook_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))
}
}
}
}
// ----------------- Page Navigation -----------------
implement_pagination_v1!(ListWebhooksResponse);