feat(api): generate reqwest and axum adapters

This commit is contained in:
2026-09-18 00:01:41 +09:00
parent 01bddf2177
commit 01f5c560c7
7 changed files with 1465 additions and 13 deletions
Generated
+8
View File
@@ -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]]
+510 -13
View File
@@ -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<Self> {
let adapters = Punctuated::<Ident, Token![,]>::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<Self> {
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<Type>,
response_body: Option<Type>,
error_body: Option<Type>,
fallible: bool,
response_status: u16,
error_status: Option<u16>,
}
@@ -175,18 +207,14 @@ struct ApiDefinition {
metadata_ident: Ident,
operations_module_ident: Ident,
operations: Vec<Operation>,
adapters: AdapterConfig,
}
fn normalize_api(
attr: proc_macro2::TokenStream,
mut item: ItemTrait,
) -> syn::Result<ApiDefinition> {
if !attr.is_empty() {
return Err(syn::Error::new(
attr.span(),
"api does not accept arguments",
));
}
let adapters = syn::parse2::<AdapterConfig>(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<Output = #output> + Send
))?;
}
Ok(())
}
fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
validate_signature(method)?;
@@ -321,7 +371,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
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<Operation> {
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<Operation> {
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<Option<u16>>
Ok(Some(status))
}
fn parse_return_type(output: &ReturnType) -> syn::Result<(Option<Type>, Option<Type>)> {
fn parse_return_type(output: &ReturnType) -> syn::Result<(Option<Type>, Option<Type>, 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<Type>, Option<T
})
.collect();
return match types.as_slice() {
[response] => 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<Type>, Option<T
};
}
Ok((Some((**ty).clone()), None))
Ok((Some((**ty).clone()), None, false))
}
fn body_type(ty: &Type) -> Option<Type> {
@@ -770,8 +822,450 @@ fn api_crate_path() -> syn::Result<proc_macro2::TokenStream> {
}
}
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 = &parameter.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 = &parameter.rust_ident;
let ty = &parameter.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 = &parameter.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 = &parameter.rust_ident;
let name = &parameter.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 = &parameter.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<A = #api_crate::reqwest::NoAuthorizer> {
core: #api_crate::reqwest::ClientCore<A>,
}
impl #client_ident<#api_crate::reqwest::NoAuthorizer> {
pub fn try_new(
base_url: impl ::core::convert::AsRef<str>,
) -> ::core::result::Result<Self, #api_crate::reqwest::ClientBuildError> {
Self::builder(base_url)?.build()
}
pub fn builder(
base_url: impl ::core::convert::AsRef<str>,
) -> ::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<A> #client_ident<A> {
pub fn from_core(core: #api_crate::reqwest::ClientCore<A>) -> Self {
Self { core }
}
}
impl<A: #api_crate::reqwest::RequestAuthorizer> #client_ident<A> {
#(#methods)*
}
impl<A> ::core::fmt::Debug for #client_ident<A> {
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<A = #api_crate::reqwest::NoAuthorizer> {
inner: #api_crate::reqwest::ClientBuilder<A>,
}
impl<A> #builder_ident<A> {
pub fn client(mut self, client: #api_crate::reqwest::framework::Client) -> Self {
self.inner = self.inner.client(client);
self
}
pub fn authorizer<B>(self, authorizer: B) -> #builder_ident<B> {
#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<A>, #api_crate::reqwest::ClientBuildError> {
self.inner.build().map(#client_ident::from_core)
}
}
impl<A> ::core::fmt::Debug for #builder_ident<A> {
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 = &parameter.rust_ident;
let ty = &parameter.ty;
quote!(#api_crate::axum::framework::Path(#ident): #api_crate::axum::framework::Path<#ty>,)
}
parameters => {
let identifiers = parameters.iter().map(|parameter| &parameter.rust_ident);
let types = parameters.iter().map(|parameter| &parameter.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 = &parameter.rust_ident;
let ty = &parameter.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 = &parameter.rust_ident;
let ty = &parameter.ty;
let name = &parameter.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 = &parameter.rust_ident;
let ty = &parameter.ty;
Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,))
});
let call_arguments = operation.parameters.iter().map(|parameter| &parameter.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<S>(service: ::std::sync::Arc<S>) -> #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::<S>))
.with_state(service)
}
async fn #handler_ident<S>(
#api_crate::axum::framework::State(__service): #api_crate::axum::framework::State<::std::sync::Arc<S>>,
#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<S>(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<proc_macro2::TokenStream> {
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<proc_macro2::TokenStream> {
}
#(#operation_impls)*
#reqwest_adapter
#axum_adapter
})
}
+22
View File
@@ -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"
+61
View File
@@ -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<T>(headers: &HeaderMap, name: &'static str) -> Result<T, StatusCode>
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<T: Serialize>(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,
};
}
+24
View File
@@ -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 {
+563
View File
@@ -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<Self, Self::Error> {
Url::parse(value)
.map_err(|_| ClientBuildError::InvalidBaseUrl)
.and_then(Self::try_from)
}
}
impl TryFrom<String> for BaseUrl {
type Error = ClientBuildError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<Url> for BaseUrl {
type Error = ClientBuildError;
fn try_from(mut value: Url) -> Result<Self, Self::Error> {
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", &"<redacted>")
.field("body", &"<redacted>")
.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<HeaderMap, AuthorizationError>;
}
/// 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<HeaderMap, AuthorizationError> {
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<A = NoAuthorizer> {
base_url: BaseUrl,
client: Option<Client>,
authorizer: A,
request_timeout: Duration,
response_body_limit: usize,
}
impl ClientBuilder<NoAuthorizer> {
pub fn try_new(base_url: impl AsRef<str>) -> Result<Self, ClientBuildError> {
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<A> ClientBuilder<A> {
pub fn client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
pub fn authorizer<B>(self, authorizer: B) -> ClientBuilder<B> {
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<ClientCore<A>, 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<A> fmt::Debug for ClientBuilder<A> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientBuilder")
.field("base_url", &self.base_url)
.field("client", &"<opaque>")
.field("authorizer", &"<redacted>")
.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<A = NoAuthorizer> {
base_url: BaseUrl,
client: Client,
authorizer: A,
request_timeout: Duration,
response_body_limit: usize,
}
impl<A: Clone> Clone for ClientCore<A> {
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<A> fmt::Debug for ClientCore<A> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientCore")
.field("base_url", &self.base_url)
.field("client", &"<opaque>")
.field("authorizer", &"<redacted>")
.field("request_timeout", &self.request_timeout)
.field("response_body_limit", &self.response_body_limit)
.finish()
}
}
impl<A> ClientCore<A> {
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<Url, ClientFailure> {
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<A: RequestAuthorizer> ClientCore<A> {
pub async fn send(
&self,
method: Method,
url: Url,
mut headers: HeaderMap,
body: Option<Vec<u8>>,
) -> Result<ReceivedResponse, ClientFailure> {
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<T: Serialize + ?Sized>(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<T: fmt::Display>(
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<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, 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<u8>,
}
impl ReceivedResponse {
pub fn status(&self) -> StatusCode {
self.status
}
pub fn decode_json<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
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", &"<redacted>")
.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<E> {
Failure(ClientFailure),
Public { status: StatusCode, error: E },
}
impl<E> ClientError<E> {
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<E> {
match self {
Self::Public { error, .. } => Some(error),
Self::Failure(_) => None,
}
}
}
impl<E> From<ClientFailure> for ClientError<E> {
fn from(value: ClientFailure) -> Self {
Self::Failure(value)
}
}
impl<E> fmt::Debug for ClientError<E> {
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", &"<redacted>")
.finish(),
}
}
}
impl<E> fmt::Display for ClientError<E> {
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<E> std::error::Error for ClientError<E> {}
/// Reexports used by generated code and advanced client construction.
pub mod framework {
pub use reqwest::{Client, Method, StatusCode, Url, header};
}
+277
View File
@@ -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<Widget, PublicError>;
#[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<Widget, PublicError> {
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<u8>,
}
#[derive(Clone)]
pub struct RecordingAuthorizer {
request: Arc<Mutex<Option<AuthorizedRequest>>>,
credential: String,
}
impl api_reqwest::RequestAuthorizer for RecordingAuthorizer {
fn authorize(
&self,
request: api_reqwest::AuthorizerRequest<'_>,
) -> Result<api_reqwest::framework::header::HeaderMap, api_reqwest::AuthorizationError> {
*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);
}