feat(api): generate reqwest and axum adapters
This commit is contained in:
Generated
+8
@@ -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]]
|
||||
|
||||
@@ -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 = ¶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<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 = ¶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<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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user