From 7f2209af1c726986f477ca70882c8a377043e170 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:31:18 -0500 Subject: [PATCH] feat: add project scaffold with core client, error types, and models Set up Cargo.toml with dependencies (reqwest, serde, thiserror, tokio, uuid, chrono). Implement OnspringClient with builder pattern and HTTP helper methods. Define error types, enums, and paging models. --- .gitignore | 5 + Cargo.toml | 21 ++++ src/client.rs | 230 +++++++++++++++++++++++++++++++++++++++++++ src/endpoints/mod.rs | 1 + src/error.rs | 22 +++++ src/lib.rs | 10 ++ src/models/enums.rs | 48 +++++++++ src/models/mod.rs | 5 + src/models/paging.rs | 36 +++++++ 9 files changed, 378 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/client.rs create mode 100644 src/endpoints/mod.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/models/enums.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/paging.rs 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>, +}