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:
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
+21
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
@@ -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::*;
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod enums;
|
||||||
|
mod paging;
|
||||||
|
|
||||||
|
pub use enums::*;
|
||||||
|
pub use paging::*;
|
||||||
@@ -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>>,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user