Files
up-api/src/v1/webhooks.rs
T
2025-02-13 15:27:55 +11:00

624 lines
19 KiB
Rust
Executable File

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);