From 01f5c560c70c2d88649ee770df7e50427007d436 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 00:01:41 +0900 Subject: [PATCH] feat(api): generate reqwest and axum adapters --- Cargo.lock | 8 + crates/api-macros-impl/src/lib.rs | 523 ++++++++++++++++++++- crates/api-macros/Cargo.toml | 22 + crates/api-macros/src/axum.rs | 61 +++ crates/api-macros/src/lib.rs | 24 + crates/api-macros/src/reqwest.rs | 563 +++++++++++++++++++++++ crates/api-macros/tests/http_adapters.rs | 277 +++++++++++ 7 files changed, 1465 insertions(+), 13 deletions(-) create mode 100644 crates/api-macros/src/axum.rs create mode 100644 crates/api-macros/src/reqwest.rs create mode 100644 crates/api-macros/tests/http_adapters.rs diff --git a/Cargo.lock b/Cargo.lock index aeed48d99..961baa889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,7 +173,15 @@ name = "api-macros" version = "0.1.0" dependencies = [ "api-macros-impl", + "axum", + "futures", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", "trybuild", + "url", ] [[package]] diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 66cd9ab0c..de30af0ae 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -11,6 +11,7 @@ use syn::{ ReturnType, Token, TraitItem, TraitItemFn, Type, Visibility, parse::{Parse, ParseStream}, parse_macro_input, + punctuated::Punctuated, spanned::Spanned, }; @@ -36,6 +37,35 @@ enum Method { Options, } +#[derive(Clone, Copy, Default)] +struct AdapterConfig { + reqwest: bool, + axum: bool, +} + +impl Parse for AdapterConfig { + fn parse(input: ParseStream<'_>) -> syn::Result { + let adapters = Punctuated::::parse_terminated(input)?; + let mut config = Self::default(); + for adapter in adapters { + match adapter.to_string().as_str() { + "reqwest" if !config.reqwest => config.reqwest = true, + "axum" if !config.axum => config.axum = true, + "reqwest" | "axum" => { + return Err(syn::Error::new(adapter.span(), "duplicate API adapter")); + } + _ => { + return Err(syn::Error::new( + adapter.span(), + "unsupported API adapter; expected `reqwest` or `axum`", + )); + } + } + } + Ok(config) + } +} + impl Method { fn from_ident(ident: &Ident) -> Option { match ident.to_string().as_str() { @@ -150,6 +180,7 @@ impl Location { } struct Parameter { + rust_ident: Ident, rust_name: String, wire_name: String, location: Location, @@ -166,6 +197,7 @@ struct Operation { request_body: Option, response_body: Option, error_body: Option, + fallible: bool, response_status: u16, error_status: Option, } @@ -175,18 +207,14 @@ struct ApiDefinition { metadata_ident: Ident, operations_module_ident: Ident, operations: Vec, + adapters: AdapterConfig, } fn normalize_api( attr: proc_macro2::TokenStream, mut item: ItemTrait, ) -> syn::Result { - if !attr.is_empty() { - return Err(syn::Error::new( - attr.span(), - "api does not accept arguments", - )); - } + let adapters = syn::parse2::(attr)?; if !matches!(item.vis, Visibility::Public(_)) { return Err(syn::Error::new_spanned( &item.ident, @@ -272,14 +300,36 @@ fn normalize_api( operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id)); + if adapters.axum || adapters.reqwest { + make_service_futures_send(&mut item)?; + } + Ok(ApiDefinition { item, metadata_ident, operations_module_ident, operations, + adapters, }) } +fn make_service_futures_send(item: &mut ItemTrait) -> syn::Result<()> { + for trait_item in &mut item.items { + let TraitItem::Fn(method) = trait_item else { + continue; + }; + method.sig.asyncness = None; + let output = match &method.sig.output { + ReturnType::Default => quote!(()), + ReturnType::Type(_, ty) => quote!(#ty), + }; + method.sig.output = syn::parse2(quote!( + -> impl ::core::future::Future + Send + ))?; + } + Ok(()) +} + fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { validate_signature(method)?; @@ -321,7 +371,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { route.operation_id.as_ref().unwrap_or(&route.path), )?; - let (response_body, error_body) = parse_return_type(&method.sig.output)?; + let (response_body, error_body, fallible) = parse_return_type(&method.sig.output)?; if let Some(ty) = response_body.as_ref() { validate_named_body_type(ty, "response")?; } @@ -435,6 +485,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { body_type = Some((*input.ty).clone()); } parameters.push(Parameter { + rust_ident: ident.clone(), rust_name, wire_name, location, @@ -482,6 +533,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { request_body: body_type, response_body, error_body, + fallible, response_status, error_status, }) @@ -657,12 +709,12 @@ fn parse_status(value: Option<&LitInt>, name: &str) -> syn::Result> Ok(Some(status)) } -fn parse_return_type(output: &ReturnType) -> syn::Result<(Option, Option)> { +fn parse_return_type(output: &ReturnType) -> syn::Result<(Option, Option, bool)> { let ReturnType::Type(_, ty) = output else { - return Ok((None, None)); + return Ok((None, None, false)); }; if is_unit(ty) { - return Ok((None, None)); + return Ok((None, None, false)); } if let Type::Path(type_path) = ty.as_ref() @@ -679,8 +731,8 @@ fn parse_return_type(output: &ReturnType) -> syn::Result<(Option, Option Ok((body_type(response), None)), - [response, error] => Ok((body_type(response), body_type(error))), + [response] => Ok((body_type(response), None, true)), + [response, error] => Ok((body_type(response), body_type(error), true)), _ => Err(syn::Error::new_spanned( arguments, "API result types must have one response type and at most one error type", @@ -688,7 +740,7 @@ fn parse_return_type(output: &ReturnType) -> syn::Result<(Option, Option Option { @@ -770,8 +822,450 @@ fn api_crate_path() -> syn::Result { } } +fn reqwest_method_tokens( + method: Method, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let method = Ident::new(method_name(method), Span::call_site()); + quote!(#api_crate::reqwest::framework::Method::#method) +} + +fn reqwest_path_segment_tokens(segment: &str, operation: &Operation) -> proc_macro2::TokenStream { + let mut pieces = Vec::new(); + let mut remaining = segment; + while let Some(start) = remaining.find('{') { + let literal = &remaining[..start]; + if !literal.is_empty() { + pieces.push(quote!(__segment.push_str(#literal);)); + } + let after = &remaining[start + 1..]; + let end = after + .find('}') + .expect("path placeholders are validated before adapter generation"); + let name = &after[..end]; + let parameter = operation + .parameters + .iter() + .find(|parameter| { + matches!(parameter.location, Location::Path) && parameter.rust_name == name + }) + .expect("path parameter names are validated before adapter generation"); + let ident = ¶meter.rust_ident; + pieces.push(quote! { + __segment.push_str(&::std::string::ToString::to_string(&#ident)); + }); + remaining = &after[end + 1..]; + } + if !remaining.is_empty() { + pieces.push(quote!(__segment.push_str(#remaining);)); + } + quote! {{ + let mut __segment = ::std::string::String::new(); + #(#pieces)* + __segment + }} +} + +fn reqwest_adapter_tokens( + api: &ApiDefinition, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + if !api.adapters.reqwest { + return quote!(); + } + let trait_ident = &api.item.ident; + let visibility = &api.item.vis; + let client_ident = format_ident!("{}Client", ident_text(trait_ident)); + let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident)); + let methods = api.operations.iter().map(|operation| { + let method_ident = &operation.method_ident; + let arguments = operation.parameters.iter().map(|parameter| { + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + quote!(#ident: #ty) + }); + let path_segments = operation + .path + .split('/') + .skip(1) + .map(|segment| reqwest_path_segment_tokens(segment, operation)); + let queries = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Query) { + return None; + } + let ident = ¶meter.rust_ident; + Some(quote! { + #api_crate::reqwest::append_query(&mut __url, &#ident) + .map_err(#api_crate::reqwest::ClientError::from)?; + }) + }); + let headers = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Header) { + return None; + } + let ident = ¶meter.rust_ident; + let name = ¶meter.wire_name; + Some(quote! { + #api_crate::reqwest::insert_header(&mut __headers, #name, &#ident) + .map_err(#api_crate::reqwest::ClientError::from)?; + }) + }); + let body = operation + .parameters + .iter() + .find(|parameter| matches!(parameter.location, Location::Body)) + .map(|parameter| { + let ident = ¶meter.rust_ident; + quote! { + ::core::option::Option::Some( + #api_crate::reqwest::encode_json(&#ident) + .map_err(#api_crate::reqwest::ClientError::from)?, + ) + } + }) + .unwrap_or_else(|| quote!(::core::option::Option::None)); + let method = reqwest_method_tokens(operation.method, api_crate); + let response_status = operation.response_status; + let response_type = operation + .response_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(())); + let error_type = operation + .error_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + let success = if let Some(response) = operation.response_body.as_ref() { + quote! { + return __response + .decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success) + .map_err(#api_crate::reqwest::ClientError::from); + } + } else { + quote! { + return __response + .require_empty() + .map_err(#api_crate::reqwest::ClientError::from); + } + }; + let public_error = match (operation.error_status, operation.error_body.as_ref()) { + (Some(status), Some(error)) => quote! { + if __actual == #status { + let __error = __response + .decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError) + .map_err(#api_crate::reqwest::ClientError::from)?; + return ::core::result::Result::Err( + #api_crate::reqwest::ClientError::public(__response.status(), __error), + ); + } + }, + _ => quote!(), + }; + quote! { + pub async fn #method_ident( + &self, + #(#arguments),* + ) -> ::core::result::Result<#response_type, #api_crate::reqwest::ClientError<#error_type>> { + let __segments = [#(#path_segments),*]; + let mut __url = self.core.endpoint(&__segments) + .map_err(#api_crate::reqwest::ClientError::from)?; + #(#queries)* + let mut __headers = #api_crate::reqwest::framework::header::HeaderMap::new(); + #(#headers)* + let __body = #body; + let __response = self.core.send(#method, __url, __headers, __body) + .await + .map_err(#api_crate::reqwest::ClientError::from)?; + let __actual = __response.status().as_u16(); + if __actual == #response_status { + #success + } + #public_error + ::core::result::Result::Err(#api_crate::reqwest::ClientError::from( + #api_crate::reqwest::ClientFailure::UnexpectedStatus { + expected: #response_status, + actual: __actual, + }, + )) + } + } + }); + + quote! { + #[doc = concat!("Typed Reqwest client for [`", stringify!(#trait_ident), "`].")] + #visibility struct #client_ident { + core: #api_crate::reqwest::ClientCore, + } + + impl #client_ident<#api_crate::reqwest::NoAuthorizer> { + pub fn try_new( + base_url: impl ::core::convert::AsRef, + ) -> ::core::result::Result { + Self::builder(base_url)?.build() + } + + pub fn builder( + base_url: impl ::core::convert::AsRef, + ) -> ::core::result::Result<#builder_ident, #api_crate::reqwest::ClientBuildError> { + Ok(#builder_ident { + inner: #api_crate::reqwest::ClientBuilder::try_new(base_url)?, + }) + } + + pub fn with_client( + base_url: #api_crate::reqwest::BaseUrl, + client: #api_crate::reqwest::framework::Client, + ) -> Self { + #builder_ident { + inner: #api_crate::reqwest::ClientBuilder::from_base_url(base_url).client(client), + } + .build() + .expect("an injected Reqwest client requires no fallible construction") + } + } + + impl #client_ident { + pub fn from_core(core: #api_crate::reqwest::ClientCore) -> Self { + Self { core } + } + } + + impl #client_ident { + #(#methods)* + } + + impl ::core::fmt::Debug for #client_ident { + fn fmt(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + formatter + .debug_struct(stringify!(#client_ident)) + .field("core", &self.core) + .finish() + } + } + + #[doc = concat!("Builder for [`", stringify!(#client_ident), "`].")] + #visibility struct #builder_ident { + inner: #api_crate::reqwest::ClientBuilder, + } + + impl #builder_ident { + pub fn client(mut self, client: #api_crate::reqwest::framework::Client) -> Self { + self.inner = self.inner.client(client); + self + } + + pub fn authorizer(self, authorizer: B) -> #builder_ident { + #builder_ident { inner: self.inner.authorizer(authorizer) } + } + + pub fn request_timeout(mut self, timeout: ::std::time::Duration) -> Self { + self.inner = self.inner.request_timeout(timeout); + self + } + + pub fn response_body_limit(mut self, limit: usize) -> Self { + self.inner = self.inner.response_body_limit(limit); + self + } + + pub fn build( + self, + ) -> ::core::result::Result<#client_ident, #api_crate::reqwest::ClientBuildError> { + self.inner.build().map(#client_ident::from_core) + } + } + + impl ::core::fmt::Debug for #builder_ident { + fn fmt(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + formatter + .debug_struct(stringify!(#builder_ident)) + .field("inner", &self.inner) + .finish() + } + } + } +} + +fn axum_routing_tokens( + method: Method, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let function = match method { + Method::Get => quote!(get), + Method::Post => quote!(post), + Method::Put => quote!(put), + Method::Patch => quote!(patch), + Method::Delete => quote!(delete), + Method::Head => quote!(head), + Method::Options => quote!(options), + }; + quote!(#api_crate::axum::framework::#function) +} + +fn ordered_path_parameters<'a>(operation: &'a Operation) -> Vec<&'a Parameter> { + let mut result = Vec::new(); + let mut remaining = operation.path.as_str(); + while let Some(start) = remaining.find('{') { + let after = &remaining[start + 1..]; + let Some(end) = after.find('}') else { break }; + let name = &after[..end]; + if let Some(parameter) = operation.parameters.iter().find(|parameter| { + matches!(parameter.location, Location::Path) && parameter.rust_name == name + }) { + result.push(parameter); + } + remaining = &after[end + 1..]; + } + result +} + +fn axum_adapter_tokens( + api: &ApiDefinition, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + if !api.adapters.axum { + return quote!(); + } + let trait_ident = &api.item.ident; + let visibility = &api.item.vis; + let adapter_ident = format_ident!("{}Axum", ident_text(trait_ident)); + let module_ident = format_ident!("{}_axum", to_snake_case(trait_ident)); + let route_functions: Vec<_> = api.operations.iter().map(|operation| { + let method_ident = &operation.method_ident; + let handler_ident = format_ident!("{}_handler", ident_text(method_ident)); + let route_path = &operation.path; + let routing = axum_routing_tokens(operation.method, api_crate); + let path_parameters = ordered_path_parameters(operation); + let path_extractor = match path_parameters.as_slice() { + [] => quote!(), + [parameter] => { + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + quote!(#api_crate::axum::framework::Path(#ident): #api_crate::axum::framework::Path<#ty>,) + } + parameters => { + let identifiers = parameters.iter().map(|parameter| ¶meter.rust_ident); + let types = parameters.iter().map(|parameter| ¶meter.ty); + quote!(#api_crate::axum::framework::Path((#(#identifiers),*)): #api_crate::axum::framework::Path<(#(#types),*)>,) + } + }; + let query_extractors = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Query) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + Some(quote!(#api_crate::axum::framework::Query(#ident): #api_crate::axum::framework::Query<#ty>,)) + }); + let has_headers = operation.parameters.iter().any(|parameter| matches!(parameter.location, Location::Header)); + let header_extractor = has_headers.then(|| quote!(__headers: #api_crate::axum::framework::HeaderMap,)); + let header_parsers = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Header) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + let name = ¶meter.wire_name; + Some(quote! { + let #ident: #ty = match #api_crate::axum::parse_header(&__headers, #name) { + Ok(value) => value, + Err(status) => return #api_crate::axum::rejection(status), + }; + }) + }); + let body_extractor = operation.parameters.iter().find_map(|parameter| { + if !matches!(parameter.location, Location::Body) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)) + }); + let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident); + let response_status = operation.response_status; + let success = if operation.response_body.is_some() { + quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value)) + } else { + quote!({ let _ = value; #api_crate::axum::empty_response(#api_crate::axum::status(#response_status)) }) + }; + let invocation = quote!(#trait_ident::#method_ident(&*__service, #(#call_arguments),*).await); + let mapped = if operation.fallible { + let error = match operation.error_status { + Some(status) => quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), error)), + None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }), + }; + quote! { + match #invocation { + Ok(value) => #success, + Err(error) => #error, + } + } + } else { + quote! { + let value = #invocation; + #success + } + }; + quote! { + pub fn #method_ident(service: ::std::sync::Arc) -> #api_crate::axum::framework::Router + where + S: super::#trait_ident + Send + Sync + 'static, + { + #api_crate::axum::framework::Router::new() + .route(#route_path, #routing(#handler_ident::)) + .with_state(service) + } + + async fn #handler_ident( + #api_crate::axum::framework::State(__service): #api_crate::axum::framework::State<::std::sync::Arc>, + #path_extractor + #(#query_extractors)* + #header_extractor + #body_extractor + ) -> #api_crate::axum::framework::Response + where + S: super::#trait_ident + Send + Sync + 'static, + { + #(#header_parsers)* + #mapped + } + } + }).collect(); + let operation_merges = api.operations.iter().map(|operation| { + let method_ident = &operation.method_ident; + quote!(__router = __router.merge(#module_ident::#method_ident(__service.clone()));) + }); + + quote! { + #[doc = concat!("Per-operation Axum routers for [`", stringify!(#trait_ident), "`].")] + #visibility mod #module_ident { + use super::*; + #(#route_functions)* + } + + #[doc = concat!("Axum router adapter for [`", stringify!(#trait_ident), "`].")] + #[derive(Clone, Copy, Debug, Default)] + #visibility struct #adapter_ident; + + impl #adapter_ident { + pub fn router(service: S) -> #api_crate::axum::framework::Router + where + S: #trait_ident + Send + Sync + 'static, + { + let __service = ::std::sync::Arc::new(service); + let mut __router = #api_crate::axum::framework::Router::new(); + #(#operation_merges)* + __router + } + } + } +} + fn expand_api(api: ApiDefinition) -> syn::Result { let api_crate = api_crate_path()?; + let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate); + let axum_adapter = axum_adapter_tokens(&api, &api_crate); let item = api.item; let metadata_ident = api.metadata_ident; let module_ident = api.operations_module_ident; @@ -848,6 +1342,9 @@ fn expand_api(api: ApiDefinition) -> syn::Result { } #(#operation_impls)* + + #reqwest_adapter + #axum_adapter }) } diff --git a/crates/api-macros/Cargo.toml b/crates/api-macros/Cargo.toml index 73515abad..6839cbcb6 100644 --- a/crates/api-macros/Cargo.toml +++ b/crates/api-macros/Cargo.toml @@ -5,8 +5,30 @@ edition.workspace = true license.workspace = true publish = false +[features] +default = [] +axum = ["dep:axum", "dep:serde"] +reqwest = [ + "dep:futures", + "dep:reqwest", + "dep:serde", + "dep:serde_json", + "dep:serde_urlencoded", + "dep:url", +] + [dependencies] api-macros-impl = { path = "../api-macros-impl" } +axum = { workspace = true, optional = true } +futures = { workspace = true, optional = true } +reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +serde_urlencoded = { version = "0.7", optional = true } +url = { version = "2", optional = true } [dev-dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net"] } trybuild = "1" diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs new file mode 100644 index 000000000..5298324dc --- /dev/null +++ b/crates/api-macros/src/axum.rs @@ -0,0 +1,61 @@ +//! Axum support used by routers generated with `#[api(axum)]`. +//! +//! Generated operation routers contain only transport extraction, typed service invocation, and +//! response mapping. Authentication/authorization context, body-limit policy, timeout policy, and +//! other application state remain explicit outer Axum layers. Each operation is also exposed as a +//! separate router so route-specific layers can be applied before routers are merged. + +use std::str::FromStr; + +use axum::{ + Json, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +/// Parse one contract header into its declared Rust type. +pub fn parse_header(headers: &HeaderMap, name: &'static str) -> Result +where + T: FromStr, +{ + headers + .get(name) + .ok_or(StatusCode::BAD_REQUEST)? + .to_str() + .map_err(|_| StatusCode::BAD_REQUEST)? + .parse() + .map_err(|_| StatusCode::BAD_REQUEST) +} + +/// Serialize a typed JSON response with the contract status. +pub fn json_response(status: StatusCode, value: T) -> Response { + (status, Json(value)).into_response() +} + +/// Emit an empty response with the contract status. +pub fn empty_response(status: StatusCode) -> Response { + status.into_response() +} + +/// Emit a transport-level rejection without a response body. +pub fn rejection(status: StatusCode) -> Response { + status.into_response() +} + +/// Convert a status constant from generated metadata. +pub fn status(code: u16) -> StatusCode { + StatusCode::from_u16(code).expect("#[api] validates HTTP status constants") +} + +/// Reexports used by generated router code. +pub mod framework { + pub use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::Response, + routing::{delete, get, head, options, patch, post, put}, + serve, + }; +} diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index 006dd6e4b..2826fbf51 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -58,11 +58,35 @@ //! Only empty and JSON bodies are accepted by this first contract. [`WireKind`] reserves //! explicit variants for future transport work; accepting one requires a deliberate macro and //! adapter change rather than silently treating it as JSON. +//! +//! # Optional HTTP adapters +//! +//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this +//! crate's `reqwest` feature is enabled. `#[api(axum)]` generates `TraitNameAxum` plus one public +//! router per operation when the `axum` feature is enabled. `#[api(reqwest, axum)]` emits both +//! from the same normalized definition. A plain `#[api]` remains framework-independent and pulls +//! neither framework into DTO/contract-only builds. +//! +//! Generated clients use a fallible `try_new` constructor and builder. The builder validates an +//! absolute credential-free HTTP(S) base URL and configures request timeout and response byte +//! limit. A prevalidated [`reqwest::BaseUrl`] and policy-configured `reqwest::framework::Client` +//! may be injected instead. Request authorizers receive the final method, encoded path/query, and +//! exact serialized body bytes; credential headers and all body content are redacted from normal +//! diagnostics. +//! +//! Generated Axum adapters keep authentication, authorization, application state, body limits, +//! and timeout layers outside the adapter. Callers may layer the complete router or an individual +//! generated operation router before merging it into their application. extern crate self as api_macros; pub use api_macros_impl::api; +#[cfg(feature = "axum")] +pub mod axum; +#[cfg(feature = "reqwest")] +pub mod reqwest; + /// A transport-neutral HTTP method. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum HttpMethod { diff --git a/crates/api-macros/src/reqwest.rs b/crates/api-macros/src/reqwest.rs new file mode 100644 index 000000000..b45406c9c --- /dev/null +++ b/crates/api-macros/src/reqwest.rs @@ -0,0 +1,563 @@ +//! Reqwest transport support used by clients generated with `#[api(reqwest)]`. +//! +//! The generated builder validates the base URL before it can be stored. Callers that enforce +//! stricter egress policy (custom DNS resolution, proxy policy, TLS roots, or connection pinning) +//! can construct their own [`reqwest::Client`] and inject it with the validated [`BaseUrl`]. + +use std::{fmt, time::Duration}; + +use futures::StreamExt as _; +use reqwest::{ + Client, Method, StatusCode, Url, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +use serde::{Serialize, de::DeserializeOwned}; + +/// Default timeout applied to the complete request, including response-body reads. +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// Default maximum response body size (1 MiB). +pub const DEFAULT_RESPONSE_BODY_LIMIT: usize = 1024 * 1024; + +/// A parsed HTTP(S) base URL without credentials, query, or fragment. +#[derive(Clone, Eq, PartialEq)] +pub struct BaseUrl(Url); + +impl BaseUrl { + /// Access the validated URL. + pub fn as_url(&self) -> &Url { + &self.0 + } +} + +impl fmt::Debug for BaseUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("BaseUrl").field(&self.0).finish() + } +} + +impl TryFrom<&str> for BaseUrl { + type Error = ClientBuildError; + + fn try_from(value: &str) -> Result { + Url::parse(value) + .map_err(|_| ClientBuildError::InvalidBaseUrl) + .and_then(Self::try_from) + } +} + +impl TryFrom for BaseUrl { + type Error = ClientBuildError; + + fn try_from(value: String) -> Result { + Self::try_from(value.as_str()) + } +} + +impl TryFrom for BaseUrl { + type Error = ClientBuildError; + + fn try_from(mut value: Url) -> Result { + if !matches!(value.scheme(), "http" | "https") + || value.cannot_be_a_base() + || value.host_str().is_none() + || !value.username().is_empty() + || value.password().is_some() + || value.query().is_some() + || value.fragment().is_some() + { + return Err(ClientBuildError::InvalidBaseUrl); + } + if !value.path().ends_with('/') { + let mut path = value.path().to_owned(); + path.push('/'); + value.set_path(&path); + } + Ok(Self(value)) + } +} + +/// Failure while constructing a generated client. +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum ClientBuildError { + /// The base URL was not an absolute credential-free HTTP(S) URL without query or fragment. + InvalidBaseUrl, + /// Reqwest could not construct its default client. + ClientConstruction, +} + +impl fmt::Debug for ClientBuildError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl fmt::Display for ClientBuildError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidBaseUrl => "invalid HTTP client base URL", + Self::ClientConstruction => "failed to construct HTTP client", + }) + } +} + +impl std::error::Error for ClientBuildError {} + +/// Exact canonical request material supplied to a [`RequestAuthorizer`]. +/// +/// `body` is the same byte slice subsequently passed to Reqwest. `path_and_query` is taken from +/// the final URL after path-segment and query encoding. +pub struct AuthorizerRequest<'a> { + pub method: &'a Method, + pub path_and_query: &'a str, + pub body: &'a [u8], +} + +impl fmt::Debug for AuthorizerRequest<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizerRequest") + .field("method", self.method) + .field("path_and_query", &"") + .field("body", &"") + .finish() + } +} + +/// Opaque authorizer failure. Secret-bearing authorizer errors are intentionally not retained. +#[derive(Clone, Copy, Default, Eq, PartialEq)] +pub struct AuthorizationError(()); + +impl AuthorizationError { + pub const fn new() -> Self { + Self(()) + } +} + +impl fmt::Debug for AuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizationError") + } +} + +impl fmt::Display for AuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("request authorization failed") + } +} + +impl std::error::Error for AuthorizationError {} + +/// Supplies credential headers for an exact encoded request. +pub trait RequestAuthorizer: Send + Sync { + fn authorize(&self, request: AuthorizerRequest<'_>) -> Result; +} + +/// Authorizer used by default; it adds no headers. +#[derive(Clone, Copy, Default)] +pub struct NoAuthorizer; + +impl RequestAuthorizer for NoAuthorizer { + fn authorize(&self, _request: AuthorizerRequest<'_>) -> Result { + Ok(HeaderMap::new()) + } +} + +impl fmt::Debug for NoAuthorizer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("NoAuthorizer") + } +} + +/// Builder shared by generated API-specific client builders. +pub struct ClientBuilder { + base_url: BaseUrl, + client: Option, + authorizer: A, + request_timeout: Duration, + response_body_limit: usize, +} + +impl ClientBuilder { + pub fn try_new(base_url: impl AsRef) -> Result { + Ok(Self::from_base_url(BaseUrl::try_from(base_url.as_ref())?)) + } + + pub fn from_base_url(base_url: BaseUrl) -> Self { + Self { + base_url, + client: None, + authorizer: NoAuthorizer, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + response_body_limit: DEFAULT_RESPONSE_BODY_LIMIT, + } + } +} + +impl ClientBuilder { + pub fn client(mut self, client: Client) -> Self { + self.client = Some(client); + self + } + + pub fn authorizer(self, authorizer: B) -> ClientBuilder { + ClientBuilder { + base_url: self.base_url, + client: self.client, + authorizer, + request_timeout: self.request_timeout, + response_body_limit: self.response_body_limit, + } + } + + pub fn request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + pub fn response_body_limit(mut self, limit: usize) -> Self { + self.response_body_limit = limit; + self + } + + pub fn build(self) -> Result, ClientBuildError> { + let client = match self.client { + Some(client) => client, + None => Client::builder() + .build() + .map_err(|_| ClientBuildError::ClientConstruction)?, + }; + Ok(ClientCore { + base_url: self.base_url, + client, + authorizer: self.authorizer, + request_timeout: self.request_timeout, + response_body_limit: self.response_body_limit, + }) + } +} + +impl fmt::Debug for ClientBuilder { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBuilder") + .field("base_url", &self.base_url) + .field("client", &"") + .field("authorizer", &"") + .field("request_timeout", &self.request_timeout) + .field("response_body_limit", &self.response_body_limit) + .finish() + } +} + +/// Framework support held by each generated API client. +pub struct ClientCore { + base_url: BaseUrl, + client: Client, + authorizer: A, + request_timeout: Duration, + response_body_limit: usize, +} + +impl Clone for ClientCore { + fn clone(&self) -> Self { + Self { + base_url: self.base_url.clone(), + client: self.client.clone(), + authorizer: self.authorizer.clone(), + request_timeout: self.request_timeout, + response_body_limit: self.response_body_limit, + } + } +} + +impl fmt::Debug for ClientCore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientCore") + .field("base_url", &self.base_url) + .field("client", &"") + .field("authorizer", &"") + .field("request_timeout", &self.request_timeout) + .field("response_body_limit", &self.response_body_limit) + .finish() + } +} + +impl ClientCore { + pub fn from_parts( + base_url: BaseUrl, + client: Client, + authorizer: A, + request_timeout: Duration, + response_body_limit: usize, + ) -> Self { + Self { + base_url, + client, + authorizer, + request_timeout, + response_body_limit, + } + } + + /// Build an endpoint by appending already-separated, unencoded path segments. + pub fn endpoint(&self, segments: &[String]) -> Result { + let mut url = self.base_url.0.clone(); + let mut path = url + .path_segments_mut() + .map_err(|_| ClientFailure::RequestEncoding)?; + path.pop_if_empty(); + for segment in segments { + path.push(segment); + } + drop(path); + Ok(url) + } + + pub fn response_body_limit(&self) -> usize { + self.response_body_limit + } +} + +impl ClientCore { + pub async fn send( + &self, + method: Method, + url: Url, + mut headers: HeaderMap, + body: Option>, + ) -> Result { + let body_bytes = body.as_deref().unwrap_or_default(); + let path_and_query = &url[url::Position::BeforePath..url::Position::AfterQuery]; + let authorization = self + .authorizer + .authorize(AuthorizerRequest { + method: &method, + path_and_query, + body: body_bytes, + }) + .map_err(|_| ClientFailure::Authorization)?; + headers.extend(authorization); + + let mut request = self + .client + .request(method, url) + .headers(headers) + .timeout(self.request_timeout); + if let Some(body) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + let response = request.send().await.map_err(|error| { + if error.is_timeout() { + ClientFailure::Timeout + } else { + ClientFailure::Transport + } + })?; + let status = response.status(); + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| { + if error.is_timeout() { + ClientFailure::Timeout + } else { + ClientFailure::Transport + } + })?; + let Some(size) = bytes.len().checked_add(chunk.len()) else { + return Err(ClientFailure::ResponseTooLarge { + limit: self.response_body_limit, + }); + }; + if size > self.response_body_limit { + return Err(ClientFailure::ResponseTooLarge { + limit: self.response_body_limit, + }); + } + bytes.extend_from_slice(&chunk); + } + Ok(ReceivedResponse { status, bytes }) + } +} + +/// Encode a serializable query DTO and append its pairs without changing existing pairs. +pub fn append_query(url: &mut Url, value: &T) -> Result<(), ClientFailure> { + let encoded = serde_urlencoded::to_string(value).map_err(|_| ClientFailure::RequestEncoding)?; + if encoded.is_empty() { + return Ok(()); + } + let combined = match url.query() { + Some(existing) if !existing.is_empty() => format!("{existing}&{encoded}"), + _ => encoded, + }; + url.set_query(Some(&combined)); + Ok(()) +} + +/// Insert a string-form header value. +pub fn insert_header( + headers: &mut HeaderMap, + name: &str, + value: &T, +) -> Result<(), ClientFailure> { + let name = + HeaderName::from_bytes(name.as_bytes()).map_err(|_| ClientFailure::RequestEncoding)?; + let value = + HeaderValue::from_str(&value.to_string()).map_err(|_| ClientFailure::RequestEncoding)?; + headers.insert(name, value); + Ok(()) +} + +/// Serialize the request body once. These exact bytes are authorized and transmitted. +pub fn encode_json(value: &T) -> Result, ClientFailure> { + serde_json::to_vec(value).map_err(|_| ClientFailure::RequestEncoding) +} + +/// Response received under the configured byte limit. Its content is always redacted in Debug. +pub struct ReceivedResponse { + status: StatusCode, + bytes: Vec, +} + +impl ReceivedResponse { + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn decode_json(&self, kind: DecodeKind) -> Result { + serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind }) + } + + pub fn require_empty(&self) -> Result<(), ClientFailure> { + if self.bytes.is_empty() { + Ok(()) + } else { + Err(ClientFailure::UnexpectedBody) + } + } +} + +impl fmt::Debug for ReceivedResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReceivedResponse") + .field("status", &self.status) + .field("bytes", &"") + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DecodeKind { + Success, + PublicError, +} + +/// Non-public-error failure from a generated client. +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum ClientFailure { + Authorization, + RequestEncoding, + Transport, + Timeout, + ResponseTooLarge { limit: usize }, + UnexpectedStatus { expected: u16, actual: u16 }, + Decode { kind: DecodeKind }, + UnexpectedBody, +} + +impl fmt::Debug for ClientFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl fmt::Display for ClientFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Authorization => formatter.write_str("request authorization failed"), + Self::RequestEncoding => formatter.write_str("request encoding failed"), + Self::Transport => formatter.write_str("HTTP transport failed"), + Self::Timeout => formatter.write_str("HTTP request timed out"), + Self::ResponseTooLarge { limit } => { + write!(formatter, "response body exceeded {limit} bytes") + } + Self::UnexpectedStatus { expected, actual } => { + write!( + formatter, + "expected HTTP status {expected}, received {actual}" + ) + } + Self::Decode { + kind: DecodeKind::Success, + } => formatter.write_str("success response JSON was malformed"), + Self::Decode { + kind: DecodeKind::PublicError, + } => formatter.write_str("public error response JSON was malformed"), + Self::UnexpectedBody => formatter.write_str("empty response contained a body"), + } + } +} + +impl std::error::Error for ClientFailure {} + +/// Typed error returned by generated client methods. +pub enum ClientError { + Failure(ClientFailure), + Public { status: StatusCode, error: E }, +} + +impl ClientError { + pub fn failure(failure: ClientFailure) -> Self { + Self::Failure(failure) + } + + pub fn public(status: StatusCode, error: E) -> Self { + Self::Public { status, error } + } + + pub fn into_public_error(self) -> Option { + match self { + Self::Public { error, .. } => Some(error), + Self::Failure(_) => None, + } + } +} + +impl From for ClientError { + fn from(value: ClientFailure) -> Self { + Self::Failure(value) + } +} + +impl fmt::Debug for ClientError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failure(failure) => formatter.debug_tuple("Failure").field(failure).finish(), + Self::Public { status, .. } => formatter + .debug_struct("Public") + .field("status", status) + .field("error", &"") + .finish(), + } + } +} + +impl fmt::Display for ClientError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failure(failure) => fmt::Display::fmt(failure, formatter), + Self::Public { status, .. } => write!(formatter, "public HTTP error {status}"), + } + } +} + +impl std::error::Error for ClientError {} + +/// Reexports used by generated code and advanced client construction. +pub mod framework { + pub use reqwest::{Client, Method, StatusCode, Url, header}; +} diff --git a/crates/api-macros/tests/http_adapters.rs b/crates/api-macros/tests/http_adapters.rs new file mode 100644 index 000000000..542d2122d --- /dev/null +++ b/crates/api-macros/tests/http_adapters.rs @@ -0,0 +1,277 @@ +#![cfg(all(feature = "reqwest", feature = "axum"))] + +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use api_macros::{api, reqwest as api_reqwest}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CreateWidget { + name: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Lookup { + mode: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Widget { + id: u64, + name: String, + mode: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PublicError { + message: String, +} + +#[api(reqwest, axum)] +pub trait WidgetApi { + #[post( + "/widgets/{widget_id}", + operation_id = "widgets.create", + status = 201, + error_status = 422 + )] + async fn create( + &self, + #[path] widget_id: u64, + #[query] lookup: Lookup, + #[header("authorization")] authorization: String, + #[body] request: CreateWidget, + ) -> Result; + + #[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)] + async fn delete(&self, #[path] widget_id: u64) -> (); +} + +#[derive(Clone)] +pub struct WidgetService; + +impl WidgetApi for WidgetService { + async fn create( + &self, + widget_id: u64, + lookup: Lookup, + authorization: String, + request: CreateWidget, + ) -> Result { + assert_eq!(authorization, "Bearer top-secret"); + if request.name == "bad" { + return Err(PublicError { + message: "sensitive-public-detail".to_owned(), + }); + } + Ok(Widget { + id: widget_id, + name: request.name, + mode: lookup.mode, + }) + } + + async fn delete(&self, _widget_id: u64) {} +} + +#[api(reqwest)] +pub trait FailureApi { + #[get("/malformed", operation_id = "failure.malformed", status = 200)] + async fn malformed(&self) -> Widget; + + #[get("/oversized", operation_id = "failure.oversized", status = 200)] + async fn oversized(&self) -> Widget; + + #[get("/slow", operation_id = "failure.slow", status = 200)] + async fn slow(&self) -> Widget; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthorizedRequest { + method: String, + path_and_query: String, + body: Vec, +} + +#[derive(Clone)] +pub struct RecordingAuthorizer { + request: Arc>>, + credential: String, +} + +impl api_reqwest::RequestAuthorizer for RecordingAuthorizer { + fn authorize( + &self, + request: api_reqwest::AuthorizerRequest<'_>, + ) -> Result { + *self.request.lock().expect("recording lock") = Some(AuthorizedRequest { + method: request.method.to_string(), + path_and_query: request.path_and_query.to_owned(), + body: request.body.to_vec(), + }); + let mut headers = api_reqwest::framework::header::HeaderMap::new(); + headers.insert( + api_reqwest::framework::header::AUTHORIZATION, + self.credential.parse().expect("valid fixture credential"), + ); + Ok(headers) + } +} + +async fn malformed() -> &'static str { + "this is not json" +} + +async fn oversized() -> String { + "response-content-that-must-never-appear-in-debug-output".to_owned() +} + +async fn slow() -> &'static str { + tokio::time::sleep(Duration::from_millis(100)).await; + "{}" +} + +#[tokio::test] +async fn generated_client_and_router_follow_the_normalized_contract() { + let app = WidgetApiAxum::router(WidgetService).merge( + api_macros::axum::framework::Router::new() + .route("/malformed", api_macros::axum::framework::get(malformed)) + .route("/oversized", api_macros::axum::framework::get(oversized)) + .route("/slow", api_macros::axum::framework::get(slow)), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fixture server"); + let address = listener.local_addr().expect("fixture address"); + let server = tokio::spawn(async move { + api_macros::axum::framework::serve(listener, app) + .await + .expect("serve fixture API"); + }); + let base_url = format!("http://{address}/"); + + let recorded = Arc::new(Mutex::new(None)); + let client = WidgetApiClient::builder(&base_url) + .expect("validated base URL") + .authorizer(RecordingAuthorizer { + request: recorded.clone(), + credential: "Bearer top-secret".to_owned(), + }) + .build() + .expect("client build"); + + let widget = client + .create( + 7, + Lookup { + mode: "full".to_owned(), + }, + "placeholder-overwritten-by-authorizer".to_owned(), + CreateWidget { + name: "alpha".to_owned(), + }, + ) + .await + .expect("typed success response"); + assert_eq!( + widget, + Widget { + id: 7, + name: "alpha".to_owned(), + mode: "full".to_owned(), + } + ); + assert_eq!( + recorded.lock().expect("recording lock").clone(), + Some(AuthorizedRequest { + method: "POST".to_owned(), + path_and_query: "/widgets/7?mode=full".to_owned(), + body: br#"{"name":"alpha"}"#.to_vec(), + }) + ); + assert!(!format!("{client:?}").contains("top-secret")); + + client.delete(7).await.expect("typed empty response"); + + let public_error = client + .create( + 8, + Lookup { + mode: "full".to_owned(), + }, + "placeholder".to_owned(), + CreateWidget { + name: "bad".to_owned(), + }, + ) + .await + .expect_err("typed public error"); + assert!(!format!("{public_error:?}").contains("sensitive-public-detail")); + assert_eq!( + public_error.into_public_error(), + Some(PublicError { + message: "sensitive-public-detail".to_owned(), + }) + ); + + let malformed_error = FailureApiClient::try_new(&base_url) + .expect("failure client") + .malformed() + .await + .expect_err("malformed JSON must fail"); + assert_eq!( + format!("{malformed_error:?}"), + "Failure(success response JSON was malformed)" + ); + assert!(!format!("{malformed_error:?}").contains("this is not json")); + + let oversized_error = FailureApiClient::builder(&base_url) + .expect("failure client builder") + .response_body_limit(8) + .build() + .expect("failure client") + .oversized() + .await + .expect_err("oversized body must fail"); + assert!(matches!( + oversized_error, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::ResponseTooLarge { + limit: 8 + }) + )); + assert!(!format!("{oversized_error:?}").contains("response-content")); + + let timeout_error = FailureApiClient::builder(&base_url) + .expect("failure client builder") + .request_timeout(Duration::from_millis(10)) + .build() + .expect("failure client") + .slow() + .await + .expect_err("slow response must time out"); + assert!(matches!( + timeout_error, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::Timeout) + )); + + let _route_specific = widget_api_axum::create(Arc::new(WidgetService)); + server.abort(); +} + +#[test] +fn base_urls_are_strict_and_named_adapter_types_are_public() { + assert!(api_reqwest::BaseUrl::try_from("ftp://example.test/").is_err()); + assert!(api_reqwest::BaseUrl::try_from("https://user@example.test/").is_err()); + assert!(api_reqwest::BaseUrl::try_from("https://example.test/?token=secret").is_err()); + assert!(api_reqwest::BaseUrl::try_from("https://example.test/#fragment").is_err()); + + fn assert_client(_: &WidgetApiClient) {} + fn assert_builder(_: &WidgetApiClientBuilder) {} + let client = WidgetApiClient::try_new("https://example.test/").expect("client"); + let builder = WidgetApiClient::builder("https://example.test/").expect("builder"); + assert_client(&client); + assert_builder(&builder); +}