diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..429dceb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +Cargo.lock +*.swp +*.swo +.env diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7dab729 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "onspring" +version = "0.1.0" +edition = "2021" +description = "Rust SDK for the Onspring API v2" +license = "MIT" + +[dependencies] +bytes = "1" +reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +uuid = { version = "1", features = ["serde", "v4"] } +chrono = { version = "0.4", features = ["serde"] } + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } +wiremock = "0.6" +serde_json = "1" diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..c21eb5a --- /dev/null +++ b/src/client.rs @@ -0,0 +1,230 @@ +use std::time::Duration; + +use bytes::Bytes; +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::error::{OnspringError, Result}; + +/// Client for interacting with the Onspring API v2. +pub struct OnspringClient { + http_client: reqwest::Client, + base_url: String, + api_key: String, +} + +/// Builder for constructing an [`OnspringClient`]. +pub struct OnspringClientBuilder { + base_url: String, + api_key: String, + http_client: Option, + timeout: Option, +} + +impl OnspringClientBuilder { + /// Creates a new builder with the given API key. + pub fn new(api_key: impl Into) -> Self { + Self { + base_url: "https://api.onspring.com".to_string(), + api_key: api_key.into(), + http_client: None, + timeout: None, + } + } + + /// Sets the base URL for the API. + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = url.into(); + self + } + + /// Sets a custom `reqwest::Client` to use for HTTP requests. + pub fn http_client(mut self, client: reqwest::Client) -> Self { + self.http_client = Some(client); + self + } + + /// Sets the request timeout. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Builds the [`OnspringClient`]. + pub fn build(self) -> OnspringClient { + let http_client = self.http_client.unwrap_or_else(|| { + reqwest::Client::builder() + .timeout(self.timeout.unwrap_or(Duration::from_secs(120))) + .build() + .expect("failed to build HTTP client") + }); + + OnspringClient { + http_client, + base_url: self.base_url.trim_end_matches('/').to_string(), + api_key: self.api_key, + } + } +} + +impl OnspringClient { + /// Creates a new [`OnspringClientBuilder`]. + pub fn builder(api_key: impl Into) -> OnspringClientBuilder { + OnspringClientBuilder::new(api_key) + } + + fn default_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "x-apikey", + HeaderValue::from_str(&self.api_key).expect("invalid API key"), + ); + headers.insert("x-api-version", HeaderValue::from_static("2")); + headers + } + + pub(crate) async fn request( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + body: Option<&impl Serialize>, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let mut req = self + .http_client + .request(method, &url) + .headers(self.default_headers()) + .query(query); + + if let Some(body) = body { + req = req.json(body); + } + + let response = req.send().await?; + let status = response.status(); + + if !status.is_success() { + let message = response + .json::() + .await + .ok() + .and_then(|v| v.get("message")?.as_str().map(String::from)) + .unwrap_or_default(); + return Err(OnspringError::Api { + status_code: status.as_u16(), + message, + }); + } + + let body = response.text().await?; + serde_json::from_str(&body).map_err(OnspringError::Serialization) + } + + pub(crate) async fn request_no_content( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + body: Option<&impl Serialize>, + ) -> Result<()> { + let url = format!("{}{}", self.base_url, path); + let mut req = self + .http_client + .request(method, &url) + .headers(self.default_headers()) + .query(query); + + if let Some(body) = body { + req = req.json(body); + } + + let response = req.send().await?; + let status = response.status(); + + if !status.is_success() { + let message = response + .json::() + .await + .ok() + .and_then(|v| v.get("message")?.as_str().map(String::from)) + .unwrap_or_default(); + return Err(OnspringError::Api { + status_code: status.as_u16(), + message, + }); + } + + Ok(()) + } + + pub(crate) async fn request_bytes( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + ) -> Result<(StatusCode, HeaderMap, Bytes)> { + let url = format!("{}{}", self.base_url, path); + let response = self + .http_client + .request(method, &url) + .headers(self.default_headers()) + .query(query) + .send() + .await?; + + let status = response.status(); + + if !status.is_success() { + let message = response + .json::() + .await + .ok() + .and_then(|v| v.get("message")?.as_str().map(String::from)) + .unwrap_or_default(); + return Err(OnspringError::Api { + status_code: status.as_u16(), + message, + }); + } + + let headers = response.headers().clone(); + let bytes = response.bytes().await?; + Ok((status, headers, bytes)) + } + + pub(crate) async fn request_multipart( + &self, + path: &str, + form: reqwest::multipart::Form, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let response = self + .http_client + .post(&url) + .headers(self.default_headers()) + .multipart(form) + .send() + .await?; + + let status = response.status(); + + if !status.is_success() { + let message = response + .json::() + .await + .ok() + .and_then(|v| v.get("message")?.as_str().map(String::from)) + .unwrap_or_default(); + return Err(OnspringError::Api { + status_code: status.as_u16(), + message, + }); + } + + let body = response.text().await?; + serde_json::from_str(&body).map_err(OnspringError::Serialization) + } +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/endpoints/mod.rs @@ -0,0 +1 @@ + diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..43dc37d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,22 @@ +/// Errors that can occur when using the Onspring API client. +#[derive(Debug, thiserror::Error)] +pub enum OnspringError { + /// An HTTP transport error occurred. + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + /// The API returned a non-success status code. + #[error("API error (status {status_code}): {message}")] + Api { status_code: u16, message: String }, + + /// A serialization or deserialization error occurred. + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// An invalid argument was provided to an SDK method. + #[error("Invalid argument: {0}")] + InvalidArgument(String), +} + +/// A type alias for `Result`. +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..04be470 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +#![allow(dead_code)] + +pub mod client; +mod endpoints; +pub mod error; +pub mod models; + +pub use client::{OnspringClient, OnspringClientBuilder}; +pub use error::{OnspringError, Result}; +pub use models::*; diff --git a/src/models/enums.rs b/src/models/enums.rs new file mode 100644 index 0000000..aad264e --- /dev/null +++ b/src/models/enums.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// The format of data returned by the API. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataFormat { + Raw, + Formatted, +} + +/// The type of report data to retrieve. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReportDataType { + ReportData, + ChartData, +} + +/// The type of a field's value in a record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ValueType { + String, + Integer, + Decimal, + Date, + TimeSpan, + Guid, + StringList, + IntegerList, + GuidList, + AttachmentList, + ScoringGroupList, + FileList, +} + +/// The output type of a formula field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FormulaOutputType { + Text, + Numeric, + DateAndTime, + ListValue, +} + +/// Whether a field allows single or multiple selections. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Multiplicity { + SingleSelect, + MultiSelect, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..b6e42b1 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,5 @@ +mod enums; +mod paging; + +pub use enums::*; +pub use paging::*; diff --git a/src/models/paging.rs b/src/models/paging.rs new file mode 100644 index 0000000..be1aee5 --- /dev/null +++ b/src/models/paging.rs @@ -0,0 +1,36 @@ +use serde::Deserialize; + +/// Parameters for paginated API requests. +#[derive(Debug, Clone)] +pub struct PagingRequest { + pub page_number: i32, + pub page_size: i32, +} + +impl Default for PagingRequest { + fn default() -> Self { + Self { + page_number: 1, + page_size: 50, + } + } +} + +/// A paginated response from the API. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PagedResponse { + pub page_number: Option, + pub page_size: Option, + pub total_pages: Option, + pub total_records: Option, + pub items: Option>, +} + +/// A collection response from the API (non-paginated). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollectionResponse { + pub count: Option, + pub items: Option>, +}