diff --git a/Cargo.lock b/Cargo.lock index 9f389434e1..54f5697ae7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5870,9 +5870,11 @@ dependencies = [ name = "nym-http-api-client-macro" version = "0.1.0" dependencies = [ + "nym-http-api-client", "proc-macro-crate", "proc-macro2", "quote", + "reqwest 0.12.22", "syn 2.0.106", ] diff --git a/common/http-api-client-macro/Cargo.toml b/common/http-api-client-macro/Cargo.toml index 1fd6869acf..c3f21073b5 100644 --- a/common/http-api-client-macro/Cargo.toml +++ b/common/http-api-client-macro/Cargo.toml @@ -19,5 +19,9 @@ syn = { workspace = true, features = ["full"] } quote = "1.0.40" proc-macro-crate = "3" +[dev-dependencies] +nym-http-api-client = { path = "../http-api-client" } +reqwest = { workspace = true } + [lints] workspace = true diff --git a/common/http-api-client-macro/src/lib.rs b/common/http-api-client-macro/src/lib.rs index 59fe60960a..ed5c216bed 100644 --- a/common/http-api-client-macro/src/lib.rs +++ b/common/http-api-client-macro/src/lib.rs @@ -1,6 +1,63 @@ -//! Proc-macros (syn 2) for configuring a reqwest-like builder via `inventory`. -//! Works both in leaf crates and inside the core crate by resolving the core -//! crate path dynamically with `proc_macro_crate`. +//! Proc-macros for configuring HTTP clients globally via the `inventory` crate. +//! +//! This crate provides macros that allow any crate in the workspace to contribute +//! configuration modifications to `reqwest::ClientBuilder` instances through a +//! compile-time registry pattern. +//! +//! # Overview +//! +//! The macros work by: +//! 1. Collecting configuration functions from across all crates at compile time +//! 2. Sorting them by priority (lower numbers run first) +//! 3. Applying them sequentially to build HTTP clients with consistent settings +//! +//! # Examples +//! +//! ## Basic Usage with `client_defaults!` +//! +//! ```ignore +//! use nym_http_api_client_macro::client_defaults; +//! +//! // Register default configurations with priority +//! client_defaults!( +//! priority = 10; // Optional, defaults to 0 +//! timeout = std::time::Duration::from_secs(30), +//! gzip = true, +//! user_agent = "MyApp/1.0" +//! ); +//! ``` +//! +//! ## Using `client_cfg!` for one-off configurations +//! +//! ```ignore +//! use nym_http_api_client_macro::client_cfg; +//! +//! let configure = client_cfg!( +//! timeout = std::time::Duration::from_secs(60), +//! default_headers { +//! "X-Custom-Header" => "value", +//! "Authorization" => auth_token +//! } +//! ); +//! +//! let builder = reqwest::ClientBuilder::new(); +//! let configured = configure(builder); +//! ``` +//! +//! # DSL Reference +//! +//! The macro DSL supports several patterns: +//! - `key = value` - Calls `builder.key(value)` +//! - `key(arg1, arg2)` - Calls `builder.key(arg1, arg2)` +//! - `flag` - Calls `builder.flag()` with no arguments +//! - `default_headers { "name" => "value", ... }` - Sets default headers +//! +//! # Priority System +//! +//! Configurations are applied in priority order (lower numbers first): +//! - Negative priorities: Early configuration (e.g., -100 for base settings) +//! - Zero (default): Standard configuration +//! - Positive priorities: Late configuration (e.g., 100 for overrides) use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; @@ -18,14 +75,14 @@ use syn::{ fn core_path() -> TokenStream2 { match crate_name("nym-http-api-client") { - Ok(FoundCrate::Itself) => quote!( crate ), + Ok(FoundCrate::Itself) => quote!(crate), Ok(FoundCrate::Name(name)) => { let ident = Ident::new(&name, Span::call_site()); quote!( ::#ident ) } Err(_) => { // Fallback if the crate is not found by name (unlikely if deps set up correctly) - quote!( ::nym_http_api_client ) + quote!(::nym_http_api_client) } } } @@ -40,10 +97,23 @@ impl Parse for Items { } enum Item { - Assign { key: Ident, _eq: Token![=], value: Expr }, // foo = EXPR - Call { key: Ident, args: Punctuated, _p: token::Paren }, // foo(a,b) - DefaultHeaders { _key: Ident, map: HeaderMapInit }, // default_headers { ... } - Flag { key: Ident }, // foo + Assign { + key: Ident, + _eq: Token![=], + value: Expr, + }, // foo = EXPR + Call { + key: Ident, + args: Punctuated, + _p: token::Paren, + }, // foo(a,b) + DefaultHeaders { + _key: Ident, + map: HeaderMapInit, + }, // default_headers { ... } + Flag { + key: Ident, + }, // foo } impl Parse for Item { @@ -69,17 +139,29 @@ impl Parse for Item { } } -struct HeaderPair { k: Expr, _arrow: Token![=>], v: Expr } +struct HeaderPair { + k: Expr, + _arrow: Token![=>], + v: Expr, +} impl Parse for HeaderPair { fn parse(input: ParseStream<'_>) -> Result { - Ok(Self { k: input.parse()?, _arrow: input.parse()?, v: input.parse()? }) + Ok(Self { + k: input.parse()?, + _arrow: input.parse()?, + v: input.parse()?, + }) } } -struct HeaderMapInit { _brace: token::Brace, pairs: Punctuated } +struct HeaderMapInit { + _brace: token::Brace, + pairs: Punctuated, +} impl Parse for HeaderMapInit { fn parse(input: ParseStream<'_>) -> Result { - let content; let _brace = braced!(content in input); + let content; + let _brace = braced!(content in input); let pairs = Punctuated::::parse_terminated(&content)?; Ok(Self { _brace, pairs }) } @@ -106,8 +188,10 @@ fn to_stmts(items: Items, core: &TokenStream2) -> TokenStream2 { #( { use #core::reqwest::header::{HeaderName, HeaderValue}; - let __k = HeaderName::try_from(#ks).expect("invalid header name"); - let __v = HeaderValue::try_from(#vs).expect("invalid header value"); + let __k = HeaderName::try_from(#ks) + .unwrap_or_else(|e| panic!("Invalid header name: {}", e)); + let __v = HeaderValue::try_from(#vs) + .unwrap_or_else(|e| panic!("Invalid header value: {}", e)); __cm.insert(__k, __v); } )* @@ -125,7 +209,20 @@ fn to_stmts(items: Items, core: &TokenStream2) -> TokenStream2 { // ------------------ client_cfg! ------------------ -/// Returns a closure: `FnOnce(ReqwestClientBuilder) -> ReqwestClientBuilder` +/// Creates a closure that configures a `ReqwestClientBuilder`. +/// +/// This macro generates a closure that can be used to configure a single +/// `reqwest::ClientBuilder` instance without affecting global defaults. +/// +/// # Example +/// +/// ```ignore +/// let config = client_cfg!( +/// timeout = Duration::from_secs(30), +/// gzip = true +/// ); +/// let client = config(reqwest::ClientBuilder::new()).build()?; +/// ``` #[proc_macro] pub fn client_cfg(input: TokenStream) -> TokenStream { let items = parse_macro_input!(input as Items); @@ -148,12 +245,9 @@ impl Parse for MaybePrioritized { // Optional header: `priority = ;` let fork = input.fork(); let mut priority = 0i32; - if fork.peek(Ident) - && fork.parse::()? == "priority" - && fork.peek(Token![=]) - { + if fork.peek(Ident) && fork.parse::()? == "priority" && fork.peek(Token![=]) { // commit - let _ = input.parse::()?; // priority + let _ = input.parse::()?; // priority let _ = input.parse::()?; let lit: LitInt = input.parse()?; priority = lit.base10_parse()?; @@ -164,6 +258,29 @@ impl Parse for MaybePrioritized { } } +/// Registers global default configurations for HTTP clients. +/// +/// This macro submits a configuration record to the global registry that will +/// be applied to all HTTP clients created with `default_builder()`. +/// +/// # Parameters +/// +/// - `priority` (optional): Integer priority for ordering (lower runs first, default: 0) +/// - Configuration items: Any valid `reqwest::ClientBuilder` method calls +/// +/// # Example +/// +/// ```ignore +/// client_defaults!( +/// priority = -50; // Run early in the configuration chain +/// connect_timeout = Duration::from_secs(10), +/// pool_max_idle_per_host = 32, +/// default_headers { +/// "User-Agent" => "MyApp/1.0", +/// "Accept" => "application/json" +/// } +/// ); +/// ``` #[proc_macro] pub fn client_defaults(input: TokenStream) -> TokenStream { let MaybePrioritized { priority, items } = parse_macro_input!(input as MaybePrioritized); diff --git a/common/http-api-client-macro/tests/macro_tests.rs b/common/http-api-client-macro/tests/macro_tests.rs new file mode 100644 index 0000000000..2696abb0f8 --- /dev/null +++ b/common/http-api-client-macro/tests/macro_tests.rs @@ -0,0 +1,64 @@ +use nym_http_api_client_macro::{client_cfg, client_defaults}; +use std::time::Duration; + +#[test] +fn test_client_cfg_basic() { + // Test that the macro compiles with basic configuration + let _config = client_cfg!(timeout = Duration::from_secs(30), gzip = true); +} + +#[test] +fn test_client_cfg_with_headers() { + // Test that the macro compiles with default headers + let _config = client_cfg!( + timeout = Duration::from_secs(30), + default_headers { + "User-Agent" => "TestApp/1.0", + "Accept" => "application/json" + } + ); +} + +#[test] +fn test_client_cfg_with_method_calls() { + // Test that the macro compiles with method calls + let _config = client_cfg!( + pool_max_idle_per_host = 32, + tcp_nodelay = true, + danger_accept_invalid_certs = true + ); +} + +#[test] +fn test_client_defaults_with_priority() { + // Test that client_defaults macro compiles with priority + client_defaults!( + priority = -100; + gzip = true, + deflate = true + ); +} + +#[test] +fn test_client_defaults_without_priority() { + // Test that client_defaults macro compiles without priority (defaults to 0) + client_defaults!(brotli = true, zstd = true); +} + +#[test] +fn test_empty_client_cfg() { + // Test that empty configuration compiles + let _config = client_cfg!(); +} + +// Integration test to verify the closure actually works +#[test] +fn test_client_cfg_closure_application() { + let config = client_cfg!(gzip = true); + + // Apply the configuration to a new builder + let builder = reqwest::ClientBuilder::new(); + let _configured_builder = config(builder); + // Note: We can't easily test the internal state of the builder, + // but we verify it compiles and runs without panic +} diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index 16de41ab8d..e27ea0b02a 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -136,10 +136,10 @@ //! ``` #![warn(missing_docs)] +pub use inventory; +pub use reqwest; pub use reqwest::ClientBuilder as ReqwestClientBuilder; pub use reqwest::StatusCode; -pub use reqwest; -pub use inventory; pub mod registry; @@ -181,10 +181,10 @@ mod path; pub use dns::{HickoryDnsError, HickoryDnsResolver}; // helper for generating user agent based on binary information +use crate::registry::default_builder; #[doc(hidden)] pub use nym_bin_common::bin_info; use nym_http_api_client_macro::client_defaults; -use crate::registry::default_builder; client_defaults!( priority = -100; diff --git a/common/http-api-client/src/registry.rs b/common/http-api-client/src/registry.rs index d2d0e5e4a7..40b22cb8dd 100644 --- a/common/http-api-client/src/registry.rs +++ b/common/http-api-client/src/registry.rs @@ -1,6 +1,14 @@ -/// A record contributed by any crate describing how to mutate a reqwest::ClientBuilder. +//! Global registry for HTTP client configurations. +//! +//! This module provides a compile-time registry system that allows any crate +//! in the workspace to contribute configuration modifications to HTTP clients. + use crate::ReqwestClientBuilder; +/// A configuration record that modifies a `ReqwestClientBuilder`. +/// +/// Records are collected at compile-time via the `inventory` crate and +/// applied in priority order when building HTTP clients. pub struct ConfigRecord { /// Lower numbers run earlier. pub priority: i32, @@ -10,18 +18,36 @@ pub struct ConfigRecord { inventory::collect!(ConfigRecord); +/// Returns the default builder with all registered configurations applied. pub fn default_builder() -> ReqwestClientBuilder { let mut b = ReqwestClientBuilder::new(); let mut records: Vec<&'static ConfigRecord> = - inventory::iter:: - .into_iter() - .collect(); + inventory::iter::.into_iter().collect(); records.sort_by_key(|r| r.priority); // lower runs first for r in records { b = (r.apply)(b); } b } + +/// Builds a client using the default builder with all registered configurations. pub fn build_client() -> reqwest::Result { default_builder().build() } + +/// Debug function to inspect registered configurations. +/// Returns a vector of (priority, function_pointer) tuples for debugging. +#[cfg(debug_assertions)] +pub fn inspect_registered_configs() -> Vec<(i32, usize)> { + let mut configs: Vec<(i32, usize)> = inventory::iter:: + .into_iter() + .map(|record| (record.priority, record.apply as usize)) + .collect(); + configs.sort_by_key(|(priority, _)| *priority); + configs +} + +/// Returns the count of registered configuration records. +pub fn registered_config_count() -> usize { + inventory::iter::.into_iter().count() +} diff --git a/common/http-api-client/tests/registry_tests.rs b/common/http-api-client/tests/registry_tests.rs new file mode 100644 index 0000000000..1f878b9444 --- /dev/null +++ b/common/http-api-client/tests/registry_tests.rs @@ -0,0 +1,70 @@ +use nym_http_api_client::registry; + +// Create separate modules to avoid name conflicts +mod config_early { + use nym_http_api_client_macro::client_defaults; + + client_defaults!( + priority = -200; + tcp_nodelay = true + ); +} + +mod config_late { + use nym_http_api_client_macro::client_defaults; + + client_defaults!( + priority = 100; + pool_idle_timeout = std::time::Duration::from_secs(90) + ); +} + +#[test] +fn test_registry_collects_configs() { + // Verify that configurations are being registered + let count = registry::registered_config_count(); + // Should have at least the ones we registered above plus the default from lib.rs + assert!( + count >= 3, + "Expected at least 3 registered configs, got {}", + count + ); +} + +#[test] +fn test_default_builder_applies_configs() { + // Test that default_builder returns a configured builder + let _builder = registry::default_builder(); + // The builder should have all configurations applied + // We can't easily inspect the internals, but we verify it doesn't panic +} + +#[test] +fn test_build_client_works() { + // Test that we can successfully build a client with all configurations + let result = registry::build_client(); + assert!(result.is_ok(), "Failed to build client: {:?}", result.err()); +} + +#[cfg(debug_assertions)] +#[test] +fn test_inspect_configs() { + // In debug mode, test that we can inspect registered configurations + let configs = registry::inspect_registered_configs(); + + // Verify configs are sorted by priority + for window in configs.windows(2) { + assert!(window[0].0 <= window[1].0, "Configs not sorted by priority"); + } + + // Verify we have configs at different priority levels + let priorities: Vec = configs.iter().map(|(p, _)| *p).collect(); + assert!( + priorities.iter().any(|&p| p < 0), + "Expected negative priority configs" + ); + assert!( + priorities.iter().any(|&p| p >= 0), + "Expected non-negative priority configs" + ); +}