beckon 0.3.0

Generate type-safe, async HTTP clients from endpoint definitions — a Rust proc macro.
Documentation
use crate::{
    error::{MacroError, MacroResult},
    input::{ApiClientInput, AuthStrategy},
};
use proc_macro2::TokenStream;
use quote::quote;
use syn::Ident;

pub mod error;
pub mod interface;
pub mod method;

pub use error::ErrorExpander;
pub use interface::TraitExpander;
pub use method::MethodExpander;

pub struct ApiClientExpander {
    input: ApiClientInput,
}

impl ApiClientExpander {
    pub fn new(input: ApiClientInput) -> Self {
        Self { input }
    }

    pub fn expand(&self) -> MacroResult<TokenStream> {
        self.validate()?;

        let struct_name = &self.input.struct_name;
        let error_name = Ident::new(&format!("{}Error", struct_name), struct_name.span());

        let error_type = ErrorExpander::new(&error_name).expand();
        let trait_def = self.expand_trait_def(&error_name)?;
        let methods = self.expand_methods(&error_name)?;
        let struct_impl = self.expand_struct_impl(&methods);

        Ok(quote! {
            #error_type
            #trait_def
            #struct_impl
        })
    }

    fn expand_trait_def(&self, error_name: &Ident) -> MacroResult<TokenStream> {
        let trait_name = self.trait_name();
        TraitExpander::new(&self.input.endpoints, &trait_name, error_name).expand()
    }

    fn expand_methods(&self, error_name: &Ident) -> MacroResult<Vec<TokenStream>> {
        let global_retries = self.input.global_retries.unwrap_or(0);
        let auth = self.input.auth.as_ref();
        self.input
            .endpoints
            .iter()
            .map(|def| {
                let retry_count = def.retries.unwrap_or(global_retries);
                MethodExpander::new(def, error_name, retry_count, auth).expand()
            })
            .collect()
    }

    fn expand_struct_impl(&self, methods: &[TokenStream]) -> TokenStream {
        let struct_name = &self.input.struct_name;
        let trait_name = self.trait_name();

        let (auth_fields, auth_params, auth_args, auth_inits) = match &self.input.auth {
            Some(AuthStrategy::Bearer) => (
                quote! { token: String, },
                quote! { token: &str, },
                quote! { token, },
                quote! { token: token.to_string(), },
            ),
            Some(AuthStrategy::Basic) => (
                quote! { username: String, password: String, },
                quote! { username: &str, password: &str, },
                quote! { username, password, },
                quote! { username: username.to_string(), password: password.to_string(), },
            ),
            Some(AuthStrategy::ApiKey(_)) => (
                quote! { api_key: String, },
                quote! { api_key: &str, },
                quote! { api_key, },
                quote! { api_key: api_key.to_string(), },
            ),
            None => (quote! {}, quote! {}, quote! {}, quote! {}),
        };

        quote! {
            #[derive(Clone)]
            pub struct #struct_name {
                url: reqwest::Url,
                client: reqwest::Client,
                timeout: std::time::Duration,
                #auth_fields
            }

            impl #struct_name {
                /// `timeout` accepts a `std::time::Duration`, or `None` for the 5s default.
                pub fn new(
                    url: reqwest::Url,
                    #auth_params
                    timeout: impl Into<Option<std::time::Duration>>,
                ) -> Self {
                    Self::with_client(url, #auth_args reqwest::Client::new(), timeout)
                }

                /// Build the client with a caller-supplied `reqwest::Client`, so a single
                /// connection pool, TLS config, proxy, or default headers can be shared.
                ///
                /// `timeout` accepts a `std::time::Duration`, or `None` for the 5s default.
                pub fn with_client(
                    url: reqwest::Url,
                    #auth_params
                    client: reqwest::Client,
                    timeout: impl Into<Option<std::time::Duration>>,
                ) -> Self {
                    let timeout = timeout.into().unwrap_or(std::time::Duration::from_secs(5));
                    Self { url, client, timeout, #auth_inits }
                }

                #[doc(hidden)]
                #[allow(dead_code)]
                fn __beckon_encode_segment(__s: &str) -> String {
                    // Percent-encode everything outside the RFC 3986 unreserved set so a
                    // path-param value like `1/2` or `a?b` can't break out of its segment.
                    const __HEX: &[u8; 16] = b"0123456789ABCDEF";
                    let mut __out = String::with_capacity(__s.len());
                    for __b in __s.bytes() {
                        match __b {
                            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
                            | b'-' | b'_' | b'.' | b'~' => __out.push(__b as char),
                            _ => {
                                __out.push('%');
                                __out.push(__HEX[(__b >> 4) as usize] as char);
                                __out.push(__HEX[(__b & 0x0f) as usize] as char);
                            }
                        }
                    }
                    __out
                }
            }

            impl #trait_name for #struct_name {
                #(#methods)*
            }
        }
    }

    fn trait_name(&self) -> Ident {
        Ident::new(
            &format!("{}Trait", self.input.struct_name),
            self.input.struct_name.span(),
        )
    }

    fn validate(&self) -> MacroResult<()> {
        if self.input.endpoints.is_empty() {
            return Err(MacroError::NoEndpointsConfigured {
                span: self.input.struct_name.span(),
            });
        }
        Ok(())
    }
}