diff --git a/Cargo.lock b/Cargo.lock index 3d750a9..8711e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4996,24 +4996,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "orb-http-client" -version = "0.1.0" -dependencies = [ - "base64 0.22.1", - "color-eyre", - "hyper 1.4.1", - "opentelemetry 0.21.0", - "orb-security-utils", - "orb-telemetry", - "reqwest 0.12.9", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-opentelemetry", -] - [[package]] name = "orb-jwk-util" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index fdb4822..4e20b3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ "hil", "jwk-util", "mcu-interface", - "mcu-util", "orb-http-client", + "mcu-util", "qr-link", "security-utils", "seek-camera/sys", diff --git a/orb-http-client/Cargo.toml b/orb-http-client/Cargo.toml deleted file mode 100644 index 097ce6a..0000000 --- a/orb-http-client/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "orb-http-client" -version = "0.1.0" -edition = "2021" - -[dependencies] -reqwest = { workspace = true, features = ["json", "rustls-tls"] } -opentelemetry = { version = "0.21", features = ["trace"] } -tracing = "0.1.40" -tracing-opentelemetry = "0.22" -color-eyre = { workspace = true } -orb-telemetry = { workspace = true } -orb-security-utils = { workspace = true, features = ["reqwest"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -base64 = "0.22.1" -hyper = "1.4.1" - -[dev-dependencies] -tokio = { version = "1", features = ["macros"] } diff --git a/orb-http-client/src/builder.rs b/orb-http-client/src/builder.rs deleted file mode 100644 index bbee8ef..0000000 --- a/orb-http-client/src/builder.rs +++ /dev/null @@ -1,547 +0,0 @@ -//! # orb-http-client -//! -//! Traced HTTP client built on [`reqwest`]. This crate automatically -//! injects OpenTelemetry trace context (W3C style) and uses pinned TLS certificates via -//! [`orb-security-utils`] to ensure secure, HTTPS-only connections by default. -//! -//! ## Key Features -//! - **Builder Pattern** for creating the traced client -//! - **Path Parameter** substitution (e.g. `/{id}` replaced with `my_id`) -//! - **Bearer & Basic Auth** support -//! - **JSON & raw body** support -//! - **HTTPS-only** and **no redirects** by default -//! - **Traced** requests (using [`tracing`] + [`opentelemetry`]), recording method, URL, status, duration, errors -//! - **Error Handling** with [`color-eyre`] -//! -//! ## Example -//! ```rust,no_run -//! use color_eyre::Result; -//! use std::time::Duration; -//! use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; -//! -//! #[tokio::main] -//! async fn main() -> Result<()> { -//! // Build a traced HTTP client, with pinned certs + TLS from orb-security-utils. -//! let client = TracedHttpClientBuilder::new() -//! .with_base_url("https://management.stage.orb.worldcoin.org") -//! .with_timeout(Duration::from_secs(10)) -//! .build()?; -//! -//! // Example usage: GET with path param + Bearer Auth -//! let orb_id = "my_orb"; -//! let token = "my_bearer_token"; -//! let response = client -//! .get("/api/v1/orbs/{id}/state") -//! .with_path_param("id", orb_id) -//! .with_auth(token) -//! .send() -//! .await?; -//! -//! println!("Status: {}", response.status()); -//! println!("Body: {}", response.text().await?); -//! Ok(()) -//! } -//! ``` - -#![deny(missing_docs)] - -use color_eyre::Result; -use opentelemetry::propagation::Injector; -use orb_security_utils::reqwest::http_client_builder; -use reqwest::{header, Body, Client, Method, Response, Url}; -use serde::Serialize; -use std::collections::HashMap; -use std::fmt; -use std::time::{Duration, Instant}; -use base64::Engine; -use reqwest::header::{HeaderMap, HeaderValue}; -use tracing::{error, field, info, Span}; -use tracing_opentelemetry::OpenTelemetrySpanExt; - -/// A builder for configuring and constructing a [`TracedHttpClient`]. -/// -/// By default, HTTPS-only and pinned certificates are enforced, thanks to -/// `orb-security-utils` with the `"reqwest"` feature enabled. Redirects are -/// disabled for security reasons. -#[derive(Default, Debug)] -pub struct TracedHttpClientBuilder { - base_url: Option, - timeout: Option, - // Potential extension points: custom headers, per-request middleware, etc. -} - -impl TracedHttpClientBuilder { - /// Create a new blank builder with default settings. - /// - /// # Example - /// ```rust - /// use orb_http_client::TracedHttpClientBuilder; - /// - /// let builder = TracedHttpClientBuilder::new(); - /// ``` - pub fn new() -> Self { - Self::default() - } - - /// Sets a base URL for all requests. Relative paths in request methods - /// (e.g. `client.get("/some/path")`) will be joined to this base URL. - /// - /// # Panics - /// If the provided string is not a valid URL. - /// - /// # Example - /// ```rust - /// # use orb_http_client::TracedHttpClientBuilder; - /// let builder = TracedHttpClientBuilder::new() - /// .with_base_url("https://example.org"); - /// ``` - pub fn with_base_url(mut self, base: &str) -> Self { - self.base_url = Some( - Url::parse(base) - .unwrap_or_else(|_| panic!("Invalid base URL provided: {}", base)), - ); - self - } - - /// Sets the maximum timeout for all requests. - /// - /// # Example - /// ```rust - /// # use orb_http_client::TracedHttpClientBuilder; - /// # use std::time::Duration; - /// let builder = TracedHttpClientBuilder::new() - /// .with_timeout(Duration::from_secs(5)); - /// ``` - pub fn with_timeout(mut self, duration: Duration) -> Self { - self.timeout = Some(duration); - self - } - - /// Builds the [`TracedHttpClient`] with the pinned TLS configuration from - /// `orb-security-utils`, respecting any settings (like `timeout`) applied here. - /// - /// # Errors - /// If the underlying `reqwest::ClientBuilder` fails to build (rare), an error is returned. - /// - /// # Example - /// ```rust,no_run - /// # use color_eyre::Result; - /// # use orb_http_client::TracedHttpClientBuilder; - /// # use std::time::Duration; - /// fn build_example() -> Result<()> { - /// let client = TracedHttpClientBuilder::new() - /// .with_base_url("https://api.example.org") - /// .with_timeout(Duration::from_secs(10)) - /// .build()?; - /// Ok(()) - /// } - /// ``` - pub fn build(self) -> Result { - let mut builder = http_client_builder(); - if let Some(t) = self.timeout { - builder = builder.timeout(t); - } - let client = builder.build()?; - - Ok(TracedHttpClient { - client, - base_url: self.base_url, - }) - } -} - -/// The main client that can build traced requests (`GET`, `POST`, etc.). -/// -/// # Example -/// ```rust,no_run -/// # use color_eyre::Result; -/// # use std::time::Duration; -/// # use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; -/// # #[tokio::main] -/// # async fn main() -> Result<()> { -/// let client = TracedHttpClientBuilder::new() -/// .with_base_url("https://example.org") -/// .with_timeout(Duration::from_secs(10)) -/// .build()?; -/// -/// let resp = client.get("/hello").send().await?; -/// println!("status: {}", resp.status()); -/// # Ok(()) -/// # } -/// ``` -#[derive(Clone)] -pub struct TracedHttpClient { - /// The underlying Reqwest client with pinned TLS config. - client: Client, - /// If set, all relative paths get joined to this. - base_url: Option, -} - -impl TracedHttpClient { - /// Creates a GET request builder for the given path. - pub fn get(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::GET, path) - } - - /// Creates a POST request builder for the given path. - pub fn post(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::POST, path) - } - - /// Creates a PUT request builder for the given path. - pub fn put(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::PUT, path) - } - - /// Creates a DELETE request builder for the given path. - pub fn delete(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::DELETE, path) - } - - /// Internal function to produce a [`TracedRequestBuilder`]. - fn request(&self, method: Method, path: &str) -> TracedRequestBuilder { - let final_url = match &self.base_url { - Some(base) => { - // Attempt to join, else parse as absolute. - base.join(path).unwrap_or_else(|_| { - Url::parse(path).unwrap_or_else(|_| { - panic!("Invalid path or URL: {}", path) - }) - }) - } - None => { - Url::parse(path).unwrap_or_else(|_| { - panic!("Invalid path or URL: {}", path) - }) - } - }; - - TracedRequestBuilder::new(self.client.clone(), method, final_url) - } -} - -impl fmt::Debug for TracedHttpClient { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TracedHttpClient") - .field("base_url", &self.base_url) - .finish() - } -} - -/// A builder for constructing and sending an HTTP request with automatic -/// OpenTelemetry trace context injection. -/// -/// This type is returned from methods like [`TracedHttpClient::get`]. -pub struct TracedRequestBuilder { - client: Client, - method: Method, - url: Url, - - // Because RequestBuilder is not Clone, we store data separately: - headers: HeaderMap, - json_body: Option, - raw_body: Option, - - span: Span, -} - -impl TracedRequestBuilder { - /// Creates a new traced request builder. Usually you won't call this directly; - /// you'll get it from [`TracedHttpClient::get`], [`TracedHttpClient::post`], etc. - pub fn new(client: Client, method: Method, url: Url) -> Self { - let span = tracing::info_span!( - "http.request", - http.method = %method, - http.url = %sanitize_url(&url) - ); - - // We start with empty custom headers, no body, etc. - Self { - client, - method, - url, - headers: HeaderMap::new(), - json_body: None, - raw_body: None, - span, - } - } - - /// Returns the underlying `Url` (for debugging, testing, etc.). - pub fn url(&self) -> &Url { - &self.url - } - - /// Replaces a path parameter placeholder (e.g. `{id}`) in the URL with the provided value. - /// - /// This is a simple string replacement on the stored `url`. Example: - /// ```rust - /// # use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; - /// # use color_eyre::Result; - /// # fn example() -> Result<()> { - /// let client = TracedHttpClientBuilder::new() - /// .with_base_url("https://example.com/api/") - /// .build()?; - /// - /// let builder = client.get("foo/{id}").with_path_param("id", "123"); - /// assert_eq!(builder.url().as_str(), "https://example.com/api/foo/123"); - /// # Ok(()) - /// # } - /// ``` - pub fn with_path_param(mut self, name: &str, value: &str) -> Self { - let old_url_str = self.url.to_string(); - let placeholder = format!("{{{}}}", name); - let new_url_str = old_url_str.replace(&placeholder, value); - - let parsed = Url::parse(&new_url_str) - .unwrap_or_else(|_| panic!("Could not parse replaced URL: {}", new_url_str)); - self.url = parsed; - self - } - - /// Adds a Bearer token header (e.g. `Authorization: Bearer `). - /// - /// # Example - /// ```rust - /// # use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; - /// # use color_eyre::Result; - /// # async fn run() -> Result<()> { - /// let client = TracedHttpClientBuilder::new().build()?; - /// let resp = client - /// .get("https://httpbin.org/bearer") - /// .with_auth("my_token") - /// .send() - /// .await?; - /// # Ok(()) - /// # } - /// ``` - pub fn with_auth(mut self, token: &str) -> Self { - let val = format!("Bearer {}", token); - self.headers.insert( - header::AUTHORIZATION, - HeaderValue::from_str(&val) - .expect("Invalid bearer auth token for header value"), - ); - self - } - - /// Adds Basic authentication credentials (`Authorization: Basic `). - /// - /// # Example - /// ```rust - /// # use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; - /// # use color_eyre::Result; - /// # async fn run() -> Result<()> { - /// let client = TracedHttpClientBuilder::new().build()?; - /// let resp = client - /// .get("https://httpbin.org/basic-auth/username/password") - /// .with_basic_auth("username", "password") - /// .send() - /// .await?; - /// # Ok(()) - /// # } - /// ``` - pub fn with_basic_auth(mut self, username: &str, password: &str) -> Self { - let credentials = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", username, password)); - self.headers.insert( - header::AUTHORIZATION, - HeaderValue::from_str(&format!("Basic {}", credentials)) - .expect("Invalid basic auth credentials for header"), - ); - self - } - /// Sets JSON body for the request. Equivalent to `reqwest::RequestBuilder::json(...)`. - /// - /// # Example - /// ```rust - /// # use serde_json::json; - /// # use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; - /// # use color_eyre::Result; - /// # async fn run() -> Result<()> { - /// let client = TracedHttpClientBuilder::new().build()?; - /// let body = json!({ "key": "value" }); - /// let resp = client - /// .post("https://httpbin.org/post") - /// .with_json(&body) - /// .send() - /// .await?; - /// # Ok(()) - /// # } - /// ``` - pub fn with_json(mut self, json_body: &T) -> Self { - let value = serde_json::to_value(json_body) - .expect("Failed to serialize JSON body"); - self.json_body = Some(value); - self - } - - /// Sets a raw body for the request. Equivalent to `reqwest::RequestBuilder::body(...)`. - pub fn with_body(mut self, body: impl Into) -> Self { - self.raw_body = Some(body.into()); - self - } - - /// Sends the request asynchronously, injecting the W3C trace context into headers - /// and recording standard HTTP attributes (status code, duration, etc.) in a tracing span. - /// - /// # Errors - /// Returns a `color-eyre::Report` if sending fails or if the response cannot be read. - /// - /// # Example - /// ```rust,no_run - /// # use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; - /// # use color_eyre::Result; - /// # async fn run() -> Result<()> { - /// let client = TracedHttpClientBuilder::new().build()?; - /// let resp = client.get("https://httpbin.org/get").send().await?; - /// println!("Status: {}", resp.status()); - /// # Ok(()) - /// # } - /// ``` - pub async fn send(self) -> Result { - let start = Instant::now(); - let _guard = self.span.enter(); - - // Gather the current OpenTelemetry context (from this span), - // so we can inject W3C trace headers. - let cx = Span::current().context(); - let mut injector = HeaderInjector::default(); - opentelemetry::global::get_text_map_propagator(|prop| { - prop.inject_context(&cx, &mut injector); - }); - - // Build a real `reqwest::RequestBuilder`. - let mut rb = self.client.request(self.method.clone(), self.url.clone()); - - // If the user set a JSON body, we apply it. Otherwise, if they set a raw body, we apply that. - if let Some(json_val) = self.json_body { - rb = rb.json(&json_val); - } else if let Some(raw) = self.raw_body { - rb = rb.body(raw); - } - - // Merge in the custom headers (auth, etc.). - for (k, v) in self.headers.iter() { - rb = rb.header(k, v.clone()); - } - - // Add the W3C trace headers - for (key, value) in injector.0 { - rb = rb.header( - header::HeaderName::from_bytes(key.as_bytes())?, - HeaderValue::from_str(&value)?, - ); - } - - // Send the actual HTTP request - let result = rb.send().await; - let duration = start.elapsed(); - self.span.record("http.duration_ms", &field::display(duration.as_millis())); - - // Log success/failure on the span - match &result { - Ok(response) => { - let status = response.status(); - self.span.record("http.status_code", &field::display(status.as_u16())); - if !status.is_success() { - error!(status = %status, "HTTP request did not succeed"); - } else { - info!(status = %status, "HTTP request succeeded"); - } - } - Err(e) => { - error!(error = ?e, "HTTP request failed"); - self.span.record("error", &true); - } - } - - drop(_guard); // exit the span - result.map_err(Into::into) - } -} - -/// A helper type to inject trace headers into the request. -#[derive(Default)] -struct HeaderInjector(HashMap); - -impl Injector for HeaderInjector { - fn set(&mut self, key: &str, value: String) { - self.0.insert(key.to_owned(), value); - } -} - - -/// Sanitizes a URL for logging/tracing, removing user info (username/password), -/// query parameters, or anything else considered sensitive. -fn sanitize_url(url: &Url) -> Url { - let mut cloned = url.clone(); - let _ = cloned.set_username(""); - let _ = cloned.set_password(None); - cloned.set_query(None); - cloned -} - -// ───────────────────────────────────────────────────────────────────────────── -// TESTS -// ───────────────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use color_eyre::Report; - use reqwest::StatusCode; - use serde_json::json; - - #[test] - fn test_builder_basics() -> Result<()> { - let client = TracedHttpClientBuilder::new() - .with_base_url("https://example.org") - .with_timeout(Duration::from_secs(10)) - .build()?; - assert_eq!(client.base_url.unwrap().as_str(), "https://example.org/"); - Ok(()) - } - - #[tokio::test] - async fn test_path_param() -> Result<()> { - let client = TracedHttpClientBuilder::new() - .with_base_url("https://example.com/api/") - .build()?; - - let builder = client.get("/foo/{id}").with_path_param("id", "42"); - assert_eq!(builder.url().as_str(), "https://example.com/api/foo/42"); - - // We won't actually send to an existing server in this test, but let's ensure no panic: - let _req = builder.with_auth("secret_token"); - Ok(()) - } - - #[tokio::test] - async fn test_json_body() -> Result<()> { - let client = TracedHttpClientBuilder::new().build()?; - let builder = client - .post("https://httpbin.org/post") - .with_json(&json!({ "hello": "world" })); - - let resp = builder.send().await?; - assert_eq!(resp.status(), StatusCode::OK); - - let json_resp: serde_json::Value = resp.json().await?; - assert_eq!(json_resp["json"]["hello"], "world"); - Ok(()) - } - - #[tokio::test] - async fn test_basic_auth() -> Result<()> { - // httpbin has an endpoint that requires basic auth: /basic-auth/user/pass - // If we provide the correct credentials, it returns 200. - let client = TracedHttpClientBuilder::new().build()?; - let builder = client - .get("https://httpbin.org/basic-auth/user/pass") - .with_basic_auth("user", "pass"); - - let resp = builder.send().await?; - assert_eq!(resp.status(), StatusCode::OK); - Ok(()) - } -} diff --git a/orb-http-client/src/client.rs b/orb-http-client/src/client.rs deleted file mode 100644 index 6c5af3e..0000000 --- a/orb-http-client/src/client.rs +++ /dev/null @@ -1,133 +0,0 @@ -use crate::request_builder::TracedRequestBuilder; -use reqwest::{Client, Method, Url}; -use std::fmt; - -/// A traced HTTP client that automatically injects OpenTelemetry trace context -/// and records spans for each request. -#[derive(Clone)] -pub struct TracedHttpClient { - /// The underlying `reqwest` client with pinned TLS configuration. - pub(crate) client: Client, - /// An optional base URL to be used for requests. - pub(crate) base_url: Option, -} - -impl TracedHttpClient { - /// Creates a new GET request builder for the given path. - pub fn get(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::GET, path) - } - - /// Creates a new POST request builder for the given path. - pub fn post(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::POST, path) - } - - /// Creates a new PUT request builder for the given path. - pub fn put(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::PUT, path) - } - - /// Creates a new DELETE request builder for the given path. - pub fn delete(&self, path: &str) -> TracedRequestBuilder { - self.request(Method::DELETE, path) - } - - /// A general function to create a new [`TracedRequestBuilder`] with a tracing span. - fn request(&self, method: Method, path: &str) -> TracedRequestBuilder { - // Try to join the path to the base URL if present, otherwise parse as a full URL. - let url = match &self.base_url { - Some(base) => { - base.join(path).unwrap_or_else(|_| { - // Fallback: parse `path` independently - Url::parse(path).unwrap_or_else(|_| { - panic!("Invalid path provided: {}", path); - }) - }) - } - None => { - Url::parse(path).unwrap_or_else(|_| { - panic!("Invalid path provided: {}", path); - }) - } - }; - - TracedRequestBuilder::new(self.client.clone(), method, url) - } -} - -impl fmt::Debug for TracedHttpClient { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TracedHttpClient") - .field("base_url", &self.base_url) - .finish() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use reqwest::StatusCode; - use std::time::Duration; - use tokio::runtime::Runtime; - - #[test] - fn test_get_request_construction() { - let rt = Runtime::new().unwrap(); - rt.block_on(async { - let client = crate::builder::TracedHttpClientBuilder::new() - .with_timeout(Duration::from_secs(5)) - .build() - .unwrap(); - - let request_builder = client.get("https://httpbin.org/get"); - // Just ensuring it doesn't panic: - let _ = request_builder; - }); - } - - #[test] - fn test_join_with_base_url() { - let client = crate::builder::TracedHttpClientBuilder::new() - .with_base_url("https://example.com/api/") - .build() - .unwrap(); - - let builder = client.get("v1/test"); - let url_str = builder.url().to_string(); - assert_eq!(url_str, "https://example.com/api/v1/test"); - } - - #[test] - fn test_fallback_invalid_join() { - let client = crate::builder::TracedHttpClientBuilder::new() - .with_base_url("https://example.com/api/") - .build() - .unwrap(); - - // "://" in "blah://" might make the join fail, so we parse `blah://path` directly. - let builder = client.get("blah://path"); - let url_str = builder.url().to_string(); - assert_eq!(url_str, "blah://path/"); - } - - #[test] - fn test_send_real_request() { - // Real network call test to httpbin.org (remove or mock for offline tests). - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let client = crate::builder::TracedHttpClientBuilder::new() - .with_timeout(Duration::from_secs(5)) - .build() - .unwrap(); - - let response = client - .get("https://httpbin.org/get") - .send() - .await - .expect("Failed to send request"); - - assert_eq!(response.status(), StatusCode::OK); - }); - } -} diff --git a/orb-http-client/src/lib.rs b/orb-http-client/src/lib.rs deleted file mode 100644 index 714e143..0000000 --- a/orb-http-client/src/lib.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! # orb-http-client -//! -//! A traced HTTP client wrapper around [`reqwest`] that automatically propagates -//! OpenTelemetry trace context and reuses [`orb-security-utils`] for pinned TLS configuration. -//! -//! This crate provides: -//! - A [`TracedHttpClient`] for making requests -//! - A builder pattern ([`TracedHttpClientBuilder`]) -//! - Automatic W3C trace context propagation -//! - Spans with key HTTP attributes (method, URL, status code, duration, errors) -//! - Support for JSON, raw bodies, Basic Auth, and Bearer Auth -//! - Security policies (HTTPS-only, disabled redirects, certificate pinning) -//! -//! ## Example -//! ```rust,no_run -//! use color_eyre::Result; -//! use orb_http_client::{TracedHttpClient, TracedHttpClientBuilder}; -//! use std::time::Duration; -//! -//! #[tokio::main] -//! async fn main() -> Result<()> { -//! let client = TracedHttpClientBuilder::new() -//! .with_base_url("https://management.stage.orb.worldcoin.org") -//! .with_timeout(Duration::from_secs(30)) -//! .build()?; -//! -//! let orb_id = "some_orb_id"; -//! let token = "some_bearer_token"; -//! -//! let response = client -//! .get("/api/v1/orbs/{id}/state") -//! .with_path_param("id", orb_id) -//! .with_auth(token) -//! .send() -//! .await?; -//! -//! println!("Response: {}", response.text().await?); -//! Ok(()) -//! } -//! ``` - -#![deny(missing_docs)] -mod builder; -mod client; -mod request_builder; - -pub use builder::TracedHttpClientBuilder; -pub use client::TracedHttpClient; -pub use request_builder::TracedRequestBuilder; diff --git a/orb-http-client/src/request_builder.rs b/orb-http-client/src/request_builder.rs deleted file mode 100644 index d6fb380..0000000 --- a/orb-http-client/src/request_builder.rs +++ /dev/null @@ -1,198 +0,0 @@ -use color_eyre::Result; -use opentelemetry::propagation::Injector; -use reqwest::{header, Body, Client, Method, Response, Url}; -use reqwest::header::{HeaderMap, HeaderValue}; -use std::collections::HashMap; -use std::time::Instant; -use base64::Engine; -use tracing::{error, field, info, Span}; -use tracing_opentelemetry::OpenTelemetrySpanExt; - -/// A builder for constructing and sending a traced HTTP request. -#[derive(Clone)] -pub struct TracedRequestBuilder { - client: Client, - method: Method, - url: Url, - headers: HeaderMap, - json_body: Option, - span: Span, -} - -impl TracedRequestBuilder { - /// Creates a new traced request builder. - pub fn new(client: Client, method: Method, url: Url) -> Self { - // Create a tracing span for this HTTP request - let span = tracing::info_span!( - "http.request", - http.method = %method, - http.url = %sanitize_url(&url) - ); - - TracedRequestBuilder { - client, - method, - url, - headers: HeaderMap::new(), - json_body: None, - span, - } - } - - /// Returns the URL used by this request. - pub fn url(&self) -> &Url { - &self.url - } - - /// Replaces a path parameter placeholder (e.g. `{id}`) in the URL with the provided value. - pub fn with_path_param(mut self, name: &str, value: &str) -> Self { - let old_url = self.url.to_string(); - let new_url = old_url.replace(&format!("{{{}}}", name), value); - - self.url = Url::parse(&new_url) - .unwrap_or_else(|_| panic!("Could not parse replaced URL {new_url}")); - - self - } - - /// Adds a Bearer token header (e.g. `Authorization: Bearer `). - pub fn with_auth(mut self, token: &str) -> Self { - self.headers.insert( - header::AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {}", token)) - .expect("Invalid bearer token for header"), - ); - self - } - - /// Adds Basic authentication credentials. - pub fn with_basic_auth(mut self, username: &str, password: &str) -> Self { - let credentials = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", username, password)); - self.headers.insert( - header::AUTHORIZATION, - HeaderValue::from_str(&format!("Basic {}", credentials)) - .expect("Invalid basic auth credentials for header"), - ); - self - } - - /// Sets JSON body for the request. - pub fn with_json(mut self, json_body: &T) -> Self { - self.json_body = Some(serde_json::to_value(json_body) - .expect("Failed to serialize JSON body")); - self - } - - /// Sets a raw body for the request. - pub fn with_body(self, body: impl Into) -> Self { - // Start building the request immediately since Body isn't Clone - self.client.request(self.method.clone(), self.url.clone()) - .body(body) - .build() - .expect("Failed to build request with body"); - self - } - - /// Sends the HTTP request, injecting W3C trace context headers and recording a tracing span. - pub async fn send(self) -> Result { - let start = Instant::now(); - let _enter = self.span.enter(); - - // Build the request with trace context - let cx = Span::current().context(); - let mut injector = HeaderInjector::default(); - opentelemetry::global::get_text_map_propagator(|propagator| { - propagator.inject_context(&cx, &mut injector); - }); - - // Start building the request - let mut builder = self.client.request(self.method, self.url); - - // Add all headers - for (k, v) in self.headers { - if let Some(key) = k { - builder = builder.header(key, v); - } - } - - // Add trace context headers - for (k, v) in injector.0 { - builder = builder.header( - header::HeaderName::from_bytes(k.as_bytes())?, - HeaderValue::from_str(&v)?, - ); - } - - // Add body if present - if let Some(json) = self.json_body { - builder = builder.json(&json); - } - - // Send the request - let result = builder.send().await; - let duration = start.elapsed(); - - self.span.record("http.duration_ms", &field::display(duration.as_millis())); - - match &result { - Ok(response) => { - let status = response.status(); - self.span.record("http.status_code", &field::display(status.as_u16())); - if !status.is_success() { - error!(status = %status, "HTTP request did not succeed"); - } else { - info!(status = %status, "HTTP request succeeded"); - } - } - Err(e) => { - error!(error = ?e, "HTTP request failed"); - self.span.record("error", &true); - } - } - - result.map_err(Into::into) - } -} - -// Helper types remain the same -#[derive(Default)] -struct HeaderInjector(HashMap); - -impl Injector for HeaderInjector { - fn set(&mut self, key: &str, value: String) { - self.0.insert(key.to_string(), value); - } -} - -fn sanitize_url(url: &Url) -> Url { - let mut sanitized = url.clone(); - let _ = sanitized.set_username(""); - let _ = sanitized.set_password(None); - sanitized.set_query(None); - sanitized -} - -#[cfg(test)] -mod tests { - use super::*; - use reqwest::StatusCode; - use tokio; - - #[tokio::test] - async fn test_sanitize_url() { - let raw = Url::parse("https://user:pass@example.com/path?secret=123").unwrap(); - let san = sanitize_url(&raw); - assert_eq!(san.username(), ""); - assert!(san.password().is_none()); - assert!(san.query().is_none()); - } - - #[tokio::test] - async fn test_simple_request() -> Result<()> { - let url = Url::parse("https://httpbin.org/get").unwrap(); - let trb = TracedRequestBuilder::new(reqwest::Client::new(), Method::GET, url); - let resp = trb.send().await?; - assert_eq!(resp.status(), StatusCode::OK); - Ok(()) - } -} \ No newline at end of file