kpl-derive 0.1.0

Procedural macros for generating API client code for stock API endpoints
Documentation
use darling::FromMeta;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};

/// Procedural macro to generate API client code for stock API endpoints
///
/// Example usage:
/// ```rust
/// #[derive(ApiEndpoint)]
/// #[endpoint(name = "历史每日涨跌统计")] // method and path are optional with defaults
/// struct HisZhangFuDetail {
///     // Fields can use serde rename attributes for proper parameter naming
///     #[serde(rename = "VerSion")]
///     ver_sion: String,
/// }
/// ```
#[derive(Debug, FromMeta)]
struct EndpointOpts {
    name: String,
    #[darling(default = "EndpointOpts::default_method")]
    method: String,
    #[darling(default = "EndpointOpts::default_path")]
    path: String,
    #[darling(default = "EndpointOpts::default_host")]
    host: String,
    #[darling(default)]
    resp: Option<syn::Path>,
}

impl EndpointOpts {
    fn default_method() -> String {
        "GET".to_string()
    }

    fn default_path() -> String {
        "/w1/api/index.php".to_string()
    }

    fn default_host() -> String {
        "apphis.longhuvip.com".to_string()
    }
}

#[proc_macro_derive(ApiEndpoint, attributes(endpoint, serde))]
pub fn api_endpoint_derive(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;

    // Parse endpoint options using darling
    let endpoint_opts = match input
        .attrs
        .iter()
        .find(|attr| attr.path().is_ident("endpoint"))
        .map(|attr| EndpointOpts::from_meta(&attr.meta))
        .transpose()
    {
        Ok(Some(opts)) => opts,
        Ok(None) => EndpointOpts {
            name: String::new(),
            method: EndpointOpts::default_method(),
            path: EndpointOpts::default_path(),
            host: EndpointOpts::default_host(),
            resp: None,
        },
        Err(e) => return TokenStream::from(e.write_errors()),
    };

    // Determine the actual return type to use in the generated code
    let actual_response_type = match &endpoint_opts.resp {
        Some(ty) => quote! { #ty },
        None => quote! { serde_json::Value },
    };

    // Extract fields for query parameters
    let fields = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(fields) => &fields.named,
            _ => panic!("ApiEndpoint only supports structs with named fields"),
        },
        _ => panic!("ApiEndpoint only supports structs"),
    };

    // Generate field accessors for query parameters
    let query_params = fields.iter().map(|field| {
        let field_name = field.ident.as_ref().unwrap();

        // Check for serde rename attribute
        let mut rename_value = None;
        for attr in &field.attrs {
            if attr.path().is_ident("serde") {
                let _ = attr.parse_nested_meta(|meta| {
                    if let Some(ident) = meta.path.get_ident() {
                        if ident == "rename" {
                            rename_value = Some(meta.value()?.parse::<syn::LitStr>()?.value());
                        }
                    }
                    Ok(())
                });
            }
        }

        // Use rename value if present, otherwise use field name
        let param_name = rename_value.unwrap_or_else(|| field_name.to_string());
        quote! {
            (#param_name, self.#field_name.to_string())
        }
    });

    let endpoint_name = &endpoint_opts.name;
    let method = &endpoint_opts.method;
    let path = &endpoint_opts.path;
    let host = &endpoint_opts.host;

    // Generate implementation with conditional deserialization logic based on response type
    let output = if endpoint_opts.resp.is_some() {
        quote! {
            impl #name {
                pub async fn execute(&self) -> Result<#actual_response_type, crate::error::ApiError> {
                    self.execute_with_host(None).await
                }

                pub async fn execute_with_host(&self, host: Option<String>) -> Result<#actual_response_type, crate::error::ApiError> {
                    use reqwest::Client;
                    use serde_json::Value;

                    let method = match reqwest::Method::from_bytes(#method.as_bytes()) {
                        Ok(m) => m,
                        Err(e) => return Err(crate::error::ApiError::InvalidMethod(e.to_string())),
                    };

                    let query_params = vec![
                        #(#query_params),*
                    ];

                    let host = host.unwrap_or_else(|| #host.to_string());

                    let response = Client::builder()
                        .danger_accept_invalid_certs(true)
                        .build()
                        .unwrap()
                        .request(method, format!("https://{}{}", host, #path))
                        .header("User-Agent", "lhb/5.18.5 (com.kaipanla.www; build:2; iOS 18.3.0) Alamofire/4.9.1")
                        .header("Content-Type", "application/x-www-form-urlencoded; application/x-www-form-urlencoded; charset=utf-8")
                        .header("Accept-Language", "zh-Hans-CN;q=1.0")
                        .query(&query_params)
                        .send()
                        .await
                        .map_err(|e| crate::error::ApiError::from(e))?
                        .error_for_status()
                        .map_err(|e| crate::error::ApiError::RequestFailed(e.status().map_or(500, |s| s.as_u16())))?;

                    let json_value: Value = response.json().await.map_err(|e| crate::error::ApiError::from(e))?;
                    Ok(<#actual_response_type>::from(json_value))
                }
            }

            impl std::fmt::Display for #name {
                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                    write!(f, "{}", #endpoint_name)
                }
            }
        }
    } else {
        quote! {
            impl #name {
                pub async fn execute(&self) -> Result<#actual_response_type, crate::error::ApiError> {
                    self.execute_with_host(None).await
                }

                pub async fn execute_with_host(&self, host: Option<String>) -> Result<#actual_response_type, crate::error::ApiError> {
                    use reqwest::Client;

                    let method = match reqwest::Method::from_bytes(#method.as_bytes()) {
                        Ok(m) => m,
                        Err(e) => return Err(crate::error::ApiError::InvalidMethod(e.to_string())),
                    };

                    let query_params = vec![
                        #(#query_params),*
                    ];

                    let host = host.unwrap_or_else(|| #host.to_string());

                    let response = Client::builder()
                        .danger_accept_invalid_certs(true)
                        .build()
                        .unwrap()
                        .request(method, format!("https://{}{}", host, #path))
                        .header("User-Agent", "lhb/5.18.5 (com.kaipanla.www; build:2; iOS 18.3.0) Alamofire/4.9.1")
                        .header("Content-Type", "application/x-www-form-urlencoded; application/x-www-form-urlencoded; charset=utf-8")
                        .header("Accept-Language", "zh-Hans-CN;q=1.0")
                        .query(&query_params)
                        .send()
                        .await
                        .map_err(crate::error::ApiError::from)?
                        .error_for_status()
                        .map_err(|e| crate::error::ApiError::RequestFailed(e.status().map_or(500, |s| s.as_u16())))?;

                    response.json::<#actual_response_type>()
                        .await
                        .map_err(ApiError::from)
                }
            }

            impl std::fmt::Display for #name {
                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                    write!(f, "{}", #endpoint_name)
                }
            }
        }
    };

    output.into()
}