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.
This commit is contained in:
Stevan Freeborn
2026-03-25 13:34:02 -05:00
parent 31d7659d1c
commit 7f2209af1c
9 changed files with 378 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
/target
Cargo.lock
*.swp
*.swo
.env
+21
View File
@@ -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"
+230
View File
@@ -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<reqwest::Client>,
timeout: Option<Duration>,
}
impl OnspringClientBuilder {
/// Creates a new builder with the given API key.
pub fn new(api_key: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<&impl Serialize>,
) -> Result<T> {
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::<serde_json::Value>()
.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::<serde_json::Value>()
.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::<serde_json::Value>()
.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<T: DeserializeOwned>(
&self,
path: &str,
form: reqwest::multipart::Form,
) -> Result<T> {
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::<serde_json::Value>()
.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)
}
}
+1
View File
@@ -0,0 +1 @@
+22
View File
@@ -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<T, OnspringError>`.
pub type Result<T> = std::result::Result<T, OnspringError>;
+10
View File
@@ -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::*;
+48
View File
@@ -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,
}
+5
View File
@@ -0,0 +1,5 @@
mod enums;
mod paging;
pub use enums::*;
pub use paging::*;
+36
View File
@@ -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<T> {
pub page_number: Option<i32>,
pub page_size: Option<i32>,
pub total_pages: Option<i32>,
pub total_records: Option<i32>,
pub items: Option<Vec<T>>,
}
/// A collection response from the API (non-paginated).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CollectionResponse<T> {
pub count: Option<i32>,
pub items: Option<Vec<T>>,
}