From 682377205574d0f0c6d004f2af0a38546c4a0cc5 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 21 Mar 2026 20:11:22 +0000 Subject: [PATCH] feat: ServiceClient trait, HttpTransport, and SunbeamClient factory Foundation layer for unified service client wrappers: - AuthMethod enum (None, Bearer, Header, Token) - ServiceClient trait with service_name(), base_url(), from_parts() - HttpTransport with json(), json_opt(), send(), bytes() helpers - SunbeamClient lazy factory with OnceLock-cached per-service clients - Feature flags for all service modules (identity, gitea, matrix, etc.) Bump: sunbeam-sdk v0.2.0 --- Cargo.lock | 56 +++- sunbeam-sdk/Cargo.toml | 22 +- sunbeam-sdk/src/client.rs | 519 ++++++++++++++++++++++++++++++++++++++ sunbeam-sdk/src/lib.rs | 20 ++ 4 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 sunbeam-sdk/src/client.rs diff --git a/Cargo.lock b/Cargo.lock index fa0c239..2f1124e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,16 @@ dependencies = [ "syn", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -683,6 +693,24 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "delegate" version = "0.13.5" @@ -1413,6 +1441,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -3562,9 +3591,10 @@ dependencies = [ [[package]] name = "sunbeam-sdk" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base64", + "bytes", "chrono", "clap", "dirs", @@ -3592,6 +3622,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "wiremock", ] [[package]] @@ -4641,6 +4672,29 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/sunbeam-sdk/Cargo.toml b/sunbeam-sdk/Cargo.toml index 721550e..1cadfb1 100644 --- a/sunbeam-sdk/Cargo.toml +++ b/sunbeam-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sunbeam-sdk" -version = "0.1.0" +version = "0.2.0" edition = "2024" description = "Sunbeam SDK — reusable library for cluster management" repository = "https://src.sunbeam.pt/studio/cli" @@ -8,8 +8,20 @@ license = "MIT" publish = ["sunbeam"] [features] -default = [] -cli = ["clap"] +default = ["identity", "gitea"] +identity = [] +gitea = [] +pm = ["gitea"] +matrix = [] +opensearch = [] +s3 = [] +livekit = [] +monitoring = [] +lasuite = [] +build = [] +cli = ["dep:clap"] +all = ["identity", "gitea", "pm", "matrix", "opensearch", "s3", "livekit", "monitoring", "lasuite", "build"] +integration = ["all"] [dependencies] # Core @@ -27,6 +39,7 @@ k8s-openapi = { version = "0.24", features = ["v1_32"] } # HTTP + TLS reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } +bytes = "1" # SSH russh = "0.46" @@ -60,6 +73,9 @@ tempfile = "3" dirs = "5" chrono = { version = "0.4", features = ["serde"] } +[dev-dependencies] +wiremock = "0.6" + [build-dependencies] reqwest = { version = "0.12", features = ["blocking", "rustls-tls"] } sha2 = "0.10" diff --git a/sunbeam-sdk/src/client.rs b/sunbeam-sdk/src/client.rs new file mode 100644 index 0000000..668240f --- /dev/null +++ b/sunbeam-sdk/src/client.rs @@ -0,0 +1,519 @@ +//! ServiceClient trait, HttpTransport, AuthMethod, and SunbeamClient factory. +//! +//! Every service client implements [`ServiceClient`] and uses [`HttpTransport`] +//! for shared HTTP plumbing. [`SunbeamClient`] lazily constructs per-service +//! clients from the active config context. + +use crate::error::{Result, ResultExt, SunbeamError}; +use reqwest::Method; +use serde::{de::DeserializeOwned, Serialize}; +use std::sync::OnceLock; + +// --------------------------------------------------------------------------- +// AuthMethod +// --------------------------------------------------------------------------- + +/// Authentication credential for API clients. +#[derive(Debug, Clone)] +pub enum AuthMethod { + /// No authentication. + None, + /// Bearer token (`Authorization: Bearer `). + Bearer(String), + /// Custom header (e.g. `X-Vault-Token`). + Header { name: &'static str, value: String }, + /// Gitea-style PAT (`Authorization: token `). + Token(String), +} + +// --------------------------------------------------------------------------- +// ServiceClient trait +// --------------------------------------------------------------------------- + +/// Base trait all service clients implement. +pub trait ServiceClient: Send + Sync { + /// Human-readable service name (e.g. "kratos", "matrix"). + fn service_name(&self) -> &'static str; + + /// The base URL this client is configured against. + fn base_url(&self) -> &str; + + /// Construct from base URL and auth. + fn from_parts(base_url: String, auth: AuthMethod) -> Self + where + Self: Sized; +} + +// --------------------------------------------------------------------------- +// HttpTransport +// --------------------------------------------------------------------------- + +/// Shared HTTP helpers any ServiceClient can use. +/// +/// Wraps a base URL, reqwest client, and auth method. Provides `json()`, +/// `json_opt()`, `send()`, `bytes()`, and `request()` to eliminate +/// per-client boilerplate. +#[derive(Debug, Clone)] +pub struct HttpTransport { + pub base_url: String, + pub http: reqwest::Client, + pub auth: AuthMethod, +} + +impl HttpTransport { + /// Create a new transport with the given base URL and auth. + pub fn new(base_url: &str, auth: AuthMethod) -> Self { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + Self { + base_url: base_url.trim_end_matches('/').to_string(), + http, + auth, + } + } + + /// Build a request with auth headers applied. + pub fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder { + let url = format!("{}/{}", self.base_url, path.trim_start_matches('/')); + let mut req = self.http.request(method, &url); + match &self.auth { + AuthMethod::None => {} + AuthMethod::Bearer(token) => { + req = req.bearer_auth(token); + } + AuthMethod::Header { name, value } => { + req = req.header(*name, value); + } + AuthMethod::Token(pat) => { + req = req.header("Authorization", format!("token {pat}")); + } + } + req + } + + /// Make a request, parse the response as JSON type `T`. + /// + /// `ctx` is a human-readable description for error messages. + pub async fn json( + &self, + method: Method, + path: &str, + body: Option<&(impl Serialize + Sync)>, + ctx: &str, + ) -> Result { + let mut req = self.request(method, path); + if let Some(b) = body { + req = req.json(b); + } + + let resp = req.send().await.with_ctx(|| format!("{ctx}: request failed"))?; + let status = resp.status(); + + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + return Err(SunbeamError::network(format!( + "{ctx}: HTTP {status}: {body_text}" + ))); + } + + resp.json::() + .await + .with_ctx(|| format!("{ctx}: failed to parse response")) + } + + /// Like [`json()`] but returns `None` on 404 instead of an error. + pub async fn json_opt( + &self, + method: Method, + path: &str, + body: Option<&(impl Serialize + Sync)>, + ctx: &str, + ) -> Result> { + let mut req = self.request(method, path); + if let Some(b) = body { + req = req.json(b); + } + + let resp = req.send().await.with_ctx(|| format!("{ctx}: request failed"))?; + let status = resp.status(); + + if status.as_u16() == 404 { + return Ok(None); + } + + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + return Err(SunbeamError::network(format!( + "{ctx}: HTTP {status}: {body_text}" + ))); + } + + let val = resp + .json::() + .await + .with_ctx(|| format!("{ctx}: failed to parse response"))?; + Ok(Some(val)) + } + + /// Make a request, discard the response body (assert success). + pub async fn send( + &self, + method: Method, + path: &str, + body: Option<&(impl Serialize + Sync)>, + ctx: &str, + ) -> Result<()> { + let mut req = self.request(method, path); + if let Some(b) = body { + req = req.json(b); + } + + let resp = req.send().await.with_ctx(|| format!("{ctx}: request failed"))?; + let status = resp.status(); + + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + return Err(SunbeamError::network(format!( + "{ctx}: HTTP {status}: {body_text}" + ))); + } + Ok(()) + } + + /// Make a request, return the raw response bytes. + pub async fn bytes( + &self, + method: Method, + path: &str, + ctx: &str, + ) -> Result { + let resp = self + .request(method, path) + .send() + .await + .with_ctx(|| format!("{ctx}: request failed"))?; + let status = resp.status(); + + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + return Err(SunbeamError::network(format!( + "{ctx}: HTTP {status}: {body_text}" + ))); + } + + resp.bytes() + .await + .with_ctx(|| format!("{ctx}: failed to read response bytes")) + } + + /// Replace the auth method (e.g. after token exchange). + pub fn set_auth(&mut self, auth: AuthMethod) { + self.auth = auth; + } +} + +// --------------------------------------------------------------------------- +// SunbeamClient factory +// --------------------------------------------------------------------------- + +/// Unified entry point for all service clients. +/// +/// Lazily constructs and caches per-service clients from the active config +/// context. Each accessor returns a `&Client` reference, constructing on +/// first call via [`OnceLock`]. +pub struct SunbeamClient { + ctx: crate::config::Context, + domain: String, + // Phase 1 + #[cfg(feature = "identity")] + kratos: OnceLock, + #[cfg(feature = "identity")] + hydra: OnceLock, + // Phase 2 + #[cfg(feature = "gitea")] + gitea: OnceLock, + // Phase 3 + #[cfg(feature = "matrix")] + matrix: OnceLock, + #[cfg(feature = "opensearch")] + opensearch: OnceLock, + #[cfg(feature = "s3")] + s3: OnceLock, + #[cfg(feature = "livekit")] + livekit: OnceLock, + #[cfg(feature = "monitoring")] + prometheus: OnceLock, + #[cfg(feature = "monitoring")] + loki: OnceLock, + #[cfg(feature = "monitoring")] + grafana: OnceLock, + // Phase 4 + #[cfg(feature = "lasuite")] + people: OnceLock, + #[cfg(feature = "lasuite")] + docs: OnceLock, + #[cfg(feature = "lasuite")] + meet: OnceLock, + #[cfg(feature = "lasuite")] + drive: OnceLock, + #[cfg(feature = "lasuite")] + messages: OnceLock, + #[cfg(feature = "lasuite")] + calendars: OnceLock, + #[cfg(feature = "lasuite")] + find: OnceLock, + // Bao/Planka stay in their existing modules + bao: OnceLock, +} + +impl SunbeamClient { + /// Create a factory from a resolved context. + pub fn from_context(ctx: &crate::config::Context) -> Self { + Self { + domain: ctx.domain.clone(), + ctx: ctx.clone(), + #[cfg(feature = "identity")] + kratos: OnceLock::new(), + #[cfg(feature = "identity")] + hydra: OnceLock::new(), + #[cfg(feature = "gitea")] + gitea: OnceLock::new(), + #[cfg(feature = "matrix")] + matrix: OnceLock::new(), + #[cfg(feature = "opensearch")] + opensearch: OnceLock::new(), + #[cfg(feature = "s3")] + s3: OnceLock::new(), + #[cfg(feature = "livekit")] + livekit: OnceLock::new(), + #[cfg(feature = "monitoring")] + prometheus: OnceLock::new(), + #[cfg(feature = "monitoring")] + loki: OnceLock::new(), + #[cfg(feature = "monitoring")] + grafana: OnceLock::new(), + #[cfg(feature = "lasuite")] + people: OnceLock::new(), + #[cfg(feature = "lasuite")] + docs: OnceLock::new(), + #[cfg(feature = "lasuite")] + meet: OnceLock::new(), + #[cfg(feature = "lasuite")] + drive: OnceLock::new(), + #[cfg(feature = "lasuite")] + messages: OnceLock::new(), + #[cfg(feature = "lasuite")] + calendars: OnceLock::new(), + #[cfg(feature = "lasuite")] + find: OnceLock::new(), + bao: OnceLock::new(), + } + } + + /// The domain this client targets. + pub fn domain(&self) -> &str { + &self.domain + } + + /// The underlying config context. + pub fn context(&self) -> &crate::config::Context { + &self.ctx + } + + // -- Lazy accessors (each feature-gated) -------------------------------- + + #[cfg(feature = "identity")] + pub fn kratos(&self) -> &crate::identity::KratosClient { + self.kratos.get_or_init(|| { + crate::identity::KratosClient::connect(&self.domain) + }) + } + + #[cfg(feature = "identity")] + pub fn hydra(&self) -> &crate::auth::hydra::HydraClient { + self.hydra.get_or_init(|| { + crate::auth::hydra::HydraClient::connect(&self.domain) + }) + } + + #[cfg(feature = "gitea")] + pub fn gitea(&self) -> &crate::gitea::GiteaClient { + self.gitea.get_or_init(|| { + crate::gitea::GiteaClient::connect(&self.domain) + }) + } + + #[cfg(feature = "matrix")] + pub fn matrix(&self) -> &crate::matrix::MatrixClient { + self.matrix.get_or_init(|| { + crate::matrix::MatrixClient::connect(&self.domain) + }) + } + + #[cfg(feature = "opensearch")] + pub fn opensearch(&self) -> &crate::search::OpenSearchClient { + self.opensearch.get_or_init(|| { + crate::search::OpenSearchClient::connect(&self.domain) + }) + } + + #[cfg(feature = "s3")] + pub fn s3(&self) -> &crate::storage::S3Client { + self.s3.get_or_init(|| { + crate::storage::S3Client::connect(&self.domain) + }) + } + + #[cfg(feature = "livekit")] + pub fn livekit(&self) -> &crate::media::LiveKitClient { + self.livekit.get_or_init(|| { + crate::media::LiveKitClient::connect(&self.domain) + }) + } + + #[cfg(feature = "monitoring")] + pub fn prometheus(&self) -> &crate::monitoring::PrometheusClient { + self.prometheus.get_or_init(|| { + crate::monitoring::PrometheusClient::connect(&self.domain) + }) + } + + #[cfg(feature = "monitoring")] + pub fn loki(&self) -> &crate::monitoring::LokiClient { + self.loki.get_or_init(|| { + crate::monitoring::LokiClient::connect(&self.domain) + }) + } + + #[cfg(feature = "monitoring")] + pub fn grafana(&self) -> &crate::monitoring::GrafanaClient { + self.grafana.get_or_init(|| { + crate::monitoring::GrafanaClient::connect(&self.domain) + }) + } + + #[cfg(feature = "lasuite")] + pub fn people(&self) -> &crate::lasuite::PeopleClient { + self.people.get_or_init(|| { + crate::lasuite::PeopleClient::connect(&self.domain) + }) + } + + #[cfg(feature = "lasuite")] + pub fn docs(&self) -> &crate::lasuite::DocsClient { + self.docs.get_or_init(|| { + crate::lasuite::DocsClient::connect(&self.domain) + }) + } + + #[cfg(feature = "lasuite")] + pub fn meet(&self) -> &crate::lasuite::MeetClient { + self.meet.get_or_init(|| { + crate::lasuite::MeetClient::connect(&self.domain) + }) + } + + #[cfg(feature = "lasuite")] + pub fn drive(&self) -> &crate::lasuite::DriveClient { + self.drive.get_or_init(|| { + crate::lasuite::DriveClient::connect(&self.domain) + }) + } + + #[cfg(feature = "lasuite")] + pub fn messages(&self) -> &crate::lasuite::MessagesClient { + self.messages.get_or_init(|| { + crate::lasuite::MessagesClient::connect(&self.domain) + }) + } + + #[cfg(feature = "lasuite")] + pub fn calendars(&self) -> &crate::lasuite::CalendarsClient { + self.calendars.get_or_init(|| { + crate::lasuite::CalendarsClient::connect(&self.domain) + }) + } + + #[cfg(feature = "lasuite")] + pub fn find(&self) -> &crate::lasuite::FindClient { + self.find.get_or_init(|| { + crate::lasuite::FindClient::connect(&self.domain) + }) + } + + pub fn bao(&self, base_url: &str) -> &crate::openbao::BaoClient { + self.bao.get_or_init(|| { + crate::openbao::BaoClient::new(base_url) + }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_http_transport_url_construction() { + let t = HttpTransport::new("https://api.example.com/", AuthMethod::None); + assert_eq!(t.base_url, "https://api.example.com"); + } + + #[test] + fn test_http_transport_strips_trailing_slash() { + let t = HttpTransport::new("https://api.example.com///", AuthMethod::None); + assert_eq!(t.base_url, "https://api.example.com"); + } + + #[test] + fn test_auth_method_variants() { + let _none = AuthMethod::None; + let _bearer = AuthMethod::Bearer("tok".into()); + let _header = AuthMethod::Header { + name: "X-Api-Key", + value: "abc".into(), + }; + let _token = AuthMethod::Token("pat123".into()); + } + + #[test] + fn test_set_auth() { + let mut t = HttpTransport::new("https://example.com", AuthMethod::None); + t.set_auth(AuthMethod::Bearer("new-token".into())); + assert!(matches!(t.auth, AuthMethod::Bearer(ref s) if s == "new-token")); + } + + #[test] + fn test_sunbeam_client_from_context() { + let ctx = crate::config::Context { + domain: "sunbeam.pt".to_string(), + ..Default::default() + }; + let client = SunbeamClient::from_context(&ctx); + assert_eq!(client.domain(), "sunbeam.pt"); + } + + #[tokio::test] + async fn test_transport_json_error_on_unreachable() { + let t = HttpTransport::new("http://127.0.0.1:19998", AuthMethod::None); + let result = t + .json::(Method::GET, "/test", Option::<&()>::None, "test") + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_transport_send_error_on_unreachable() { + let t = HttpTransport::new("http://127.0.0.1:19998", AuthMethod::None); + let result = t + .send(Method::GET, "/test", Option::<&()>::None, "test") + .await; + assert!(result.is_err()); + } +} diff --git a/sunbeam-sdk/src/lib.rs b/sunbeam-sdk/src/lib.rs index 39ddabb..e4c7bff 100644 --- a/sunbeam-sdk/src/lib.rs +++ b/sunbeam-sdk/src/lib.rs @@ -1,6 +1,8 @@ #[macro_use] pub mod error; +pub mod client; + pub mod auth; pub mod checks; pub mod cluster; @@ -17,3 +19,21 @@ pub mod secrets; pub mod services; pub mod update; pub mod users; + +// Feature-gated service client modules +#[cfg(feature = "identity")] +pub mod identity; +#[cfg(feature = "matrix")] +pub mod matrix; +#[cfg(feature = "opensearch")] +pub mod search; +#[cfg(feature = "s3")] +pub mod storage; +#[cfg(feature = "livekit")] +pub mod media; +#[cfg(feature = "monitoring")] +pub mod monitoring; +#[cfg(feature = "lasuite")] +pub mod lasuite; +#[cfg(feature = "build")] +pub mod build;