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"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api-macros-impl",
|
"api-macros-impl",
|
||||||
|
"axum",
|
||||||
|
"futures",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"tokio",
|
||||||
"trybuild",
|
"trybuild",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use syn::{
|
|||||||
ReturnType, Token, TraitItem, TraitItemFn, Type, Visibility,
|
ReturnType, Token, TraitItem, TraitItemFn, Type, Visibility,
|
||||||
parse::{Parse, ParseStream},
|
parse::{Parse, ParseStream},
|
||||||
parse_macro_input,
|
parse_macro_input,
|
||||||
|
punctuated::Punctuated,
|
||||||
spanned::Spanned,
|
spanned::Spanned,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +37,35 @@ enum Method {
|
|||||||
Options,
|
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 {
|
impl Method {
|
||||||
fn from_ident(ident: &Ident) -> Option<Self> {
|
fn from_ident(ident: &Ident) -> Option<Self> {
|
||||||
match ident.to_string().as_str() {
|
match ident.to_string().as_str() {
|
||||||
@@ -150,6 +180,7 @@ impl Location {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct Parameter {
|
struct Parameter {
|
||||||
|
rust_ident: Ident,
|
||||||
rust_name: String,
|
rust_name: String,
|
||||||
wire_name: String,
|
wire_name: String,
|
||||||
location: Location,
|
location: Location,
|
||||||
@@ -166,6 +197,7 @@ struct Operation {
|
|||||||
request_body: Option<Type>,
|
request_body: Option<Type>,
|
||||||
response_body: Option<Type>,
|
response_body: Option<Type>,
|
||||||
error_body: Option<Type>,
|
error_body: Option<Type>,
|
||||||
|
fallible: bool,
|
||||||
response_status: u16,
|
response_status: u16,
|
||||||
error_status: Option<u16>,
|
error_status: Option<u16>,
|
||||||
}
|
}
|
||||||
@@ -175,18 +207,14 @@ struct ApiDefinition {
|
|||||||
metadata_ident: Ident,
|
metadata_ident: Ident,
|
||||||
operations_module_ident: Ident,
|
operations_module_ident: Ident,
|
||||||
operations: Vec<Operation>,
|
operations: Vec<Operation>,
|
||||||
|
adapters: AdapterConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_api(
|
fn normalize_api(
|
||||||
attr: proc_macro2::TokenStream,
|
attr: proc_macro2::TokenStream,
|
||||||
mut item: ItemTrait,
|
mut item: ItemTrait,
|
||||||
) -> syn::Result<ApiDefinition> {
|
) -> syn::Result<ApiDefinition> {
|
||||||
if !attr.is_empty() {
|
let adapters = syn::parse2::<AdapterConfig>(attr)?;
|
||||||
return Err(syn::Error::new(
|
|
||||||
attr.span(),
|
|
||||||
"api does not accept arguments",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !matches!(item.vis, Visibility::Public(_)) {
|
if !matches!(item.vis, Visibility::Public(_)) {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
&item.ident,
|
&item.ident,
|
||||||
@@ -272,14 +300,36 @@ fn normalize_api(
|
|||||||
|
|
||||||
operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id));
|
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 {
|
Ok(ApiDefinition {
|
||||||
item,
|
item,
|
||||||
metadata_ident,
|
metadata_ident,
|
||||||
operations_module_ident,
|
operations_module_ident,
|
||||||
operations,
|
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> {
|
fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||||
validate_signature(method)?;
|
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),
|
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() {
|
if let Some(ty) = response_body.as_ref() {
|
||||||
validate_named_body_type(ty, "response")?;
|
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());
|
body_type = Some((*input.ty).clone());
|
||||||
}
|
}
|
||||||
parameters.push(Parameter {
|
parameters.push(Parameter {
|
||||||
|
rust_ident: ident.clone(),
|
||||||
rust_name,
|
rust_name,
|
||||||
wire_name,
|
wire_name,
|
||||||
location,
|
location,
|
||||||
@@ -482,6 +533,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
request_body: body_type,
|
request_body: body_type,
|
||||||
response_body,
|
response_body,
|
||||||
error_body,
|
error_body,
|
||||||
|
fallible,
|
||||||
response_status,
|
response_status,
|
||||||
error_status,
|
error_status,
|
||||||
})
|
})
|
||||||
@@ -657,12 +709,12 @@ fn parse_status(value: Option<&LitInt>, name: &str) -> syn::Result<Option<u16>>
|
|||||||
Ok(Some(status))
|
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 {
|
let ReturnType::Type(_, ty) = output else {
|
||||||
return Ok((None, None));
|
return Ok((None, None, false));
|
||||||
};
|
};
|
||||||
if is_unit(ty) {
|
if is_unit(ty) {
|
||||||
return Ok((None, None));
|
return Ok((None, None, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Type::Path(type_path) = ty.as_ref()
|
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();
|
.collect();
|
||||||
return match types.as_slice() {
|
return match types.as_slice() {
|
||||||
[response] => Ok((body_type(response), None)),
|
[response] => Ok((body_type(response), None, true)),
|
||||||
[response, error] => Ok((body_type(response), body_type(error))),
|
[response, error] => Ok((body_type(response), body_type(error), true)),
|
||||||
_ => Err(syn::Error::new_spanned(
|
_ => Err(syn::Error::new_spanned(
|
||||||
arguments,
|
arguments,
|
||||||
"API result types must have one response type and at most one error type",
|
"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> {
|
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> {
|
fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
||||||
let api_crate = api_crate_path()?;
|
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 item = api.item;
|
||||||
let metadata_ident = api.metadata_ident;
|
let metadata_ident = api.metadata_ident;
|
||||||
let module_ident = api.operations_module_ident;
|
let module_ident = api.operations_module_ident;
|
||||||
@@ -848,6 +1342,9 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#(#operation_impls)*
|
#(#operation_impls)*
|
||||||
|
|
||||||
|
#reqwest_adapter
|
||||||
|
#axum_adapter
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,30 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish = false
|
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]
|
[dependencies]
|
||||||
api-macros-impl = { path = "../api-macros-impl" }
|
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]
|
[dev-dependencies]
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["net"] }
|
||||||
trybuild = "1"
|
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
|
//! 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
|
//! explicit variants for future transport work; accepting one requires a deliberate macro and
|
||||||
//! adapter change rather than silently treating it as JSON.
|
//! 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;
|
extern crate self as api_macros;
|
||||||
|
|
||||||
pub use api_macros_impl::api;
|
pub use api_macros_impl::api;
|
||||||
|
|
||||||
|
#[cfg(feature = "axum")]
|
||||||
|
pub mod axum;
|
||||||
|
#[cfg(feature = "reqwest")]
|
||||||
|
pub mod reqwest;
|
||||||
|
|
||||||
/// A transport-neutral HTTP method.
|
/// A transport-neutral HTTP method.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
pub enum HttpMethod {
|
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