toolfront-macro 0.1.0

A procedural macro for generating type-safe API clients from OpenAPI endpoints
Documentation
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse::Parse, parse_macro_input, Ident, LitStr};

struct ApiEndpoint {
    client_name: Ident,
    path: LitStr,
    method: LitStr,
    params_type: syn::Type,
    response_type: syn::Type,
    error_type: syn::Type,
}

impl Parse for ApiEndpoint {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        let client_name: Ident = input.parse()?;
        input.parse::<syn::Token![,]>()?;
        let path: LitStr = input.parse()?;
        input.parse::<syn::Token![,]>()?;
        let method: LitStr = input.parse()?;
        input.parse::<syn::Token![,]>()?;
        let params_type: syn::Type = input.parse()?;
        input.parse::<syn::Token![,]>()?;
        let response_type: syn::Type = input.parse()?;
        input.parse::<syn::Token![,]>()?;
        let error_type: syn::Type = input.parse()?;

        Ok(ApiEndpoint {
            client_name,
            path,
            method,
            params_type,
            response_type,
            error_type,
        })
    }
}

/// Generate a type-safe API client for an OpenAPI endpoint. This macro is designed to work
/// within an agent-based API automation system that uses RAG (Retrieval Augmented Generation)
/// to find and execute relevant API endpoints.
///
/// # System Overview
///
/// The typical workflow:
/// 1. OpenAPI specs are downloaded and stored in the database
/// 2. Endpoints are extracted and embedded for RAG retrieval
/// 3. When a natural language task arrives, relevant endpoints are retrieved
/// 4. This macro generates type-safe clients for those endpoints
///
/// # Usage Example
///
/// ```rust
/// use serde::{Serialize, Deserialize};
/// use pgvector::Vector;
/// use uuid::Uuid;
///
/// // Define your custom error type
/// #[derive(Debug, thiserror::Error)]
/// pub enum AgentError {
///     #[error("API request failed: {0}")]
///     Request(#[from] reqwest::Error),
///     #[error("JSON error: {0}")]
///     Json(#[from] serde_json::Error),
///     // ... other error variants as needed
/// }
///
/// // Define your request and response types
/// #[derive(Debug, Serialize)]
/// struct SearchUsersParams {
///     query: String,
///     max_results: i32,
///     include_inactive: bool,
/// }
///
/// #[derive(Debug, Deserialize)]
/// struct UserSearchResponse {
///     users: Vec<User>,
///     total_count: i32,
///     page_token: Option<String>,
/// }
///
/// // Generate the client with your custom error type
/// generate_client!(
///     UserSearchClient,                // Name for the generated client
///     "/api/v1/users/search",         // Endpoint path
///     "POST",                         // HTTP method
///     SearchUsersParams,              // Parameters type
///     UserSearchResponse,             // Response type
///     AgentError                      // Your custom error type
/// );
///
/// // Example usage in an agent system
/// struct Agent {
///     openai: OpenAIClient,
///     db: PgPool,
/// }
///
/// impl Agent {
///     async fn execute_task(&self, task: &str) -> Result<serde_json::Value, AgentError> {
///         // Find relevant endpoint using RAG
///         let endpoint = find_relevant_endpoint(&self.db, task).await?;
///         
///         // Generate parameters using LLM
///         let params = self.generate_parameters(task).await?;
///         
///         // Execute the API call using our generated client
///         let client = UserSearchClient::new("https://api.example.com".to_string());
///         let response = client.execute(params).await?;
///         
///         Ok(serde_json::to_value(response)?)
///     }
/// }
/// ```
///
/// # Parameters
///
/// * `client_name`: The name of the generated client struct
/// * `path`: The endpoint path template (e.g., "/users/{id}/posts")
/// * `method`: The HTTP method as a string (e.g., "GET", "POST")
/// * `params_type`: The request parameters type (must implement Serialize)
/// * `response_type`: The response type (must implement Deserialize)
/// * `error_type`: Your custom error type that implements From<reqwest::Error> and From<serde_json::Error>
///
/// # Generated Client
///
/// The macro generates a client struct with:
/// - Constructor for base URL configuration
/// - Type-safe execute method that handles:
///   - Path parameter substitution
///   - Request body serialization
///   - Response deserialization
///   - Error conversion to your custom type
///
/// # Error Handling
///
/// The generated client returns `Result<T, E>` where E is your custom error type.
/// Your error type must implement:
/// ```rust
/// impl From<reqwest::Error> for YourErrorType { ... }
/// impl From<serde_json::Error> for YourErrorType { ... }
/// ```
///
/// Common error cases that will be converted to your error type:
/// - URL construction failures
/// - Network errors from reqwest
/// - Non-200 HTTP responses
/// - JSON serialization/deserialization errors
#[proc_macro]
pub fn generate_client(input: TokenStream) -> TokenStream {
    let ApiEndpoint {
        client_name,
        path,
        method,
        params_type,
        response_type,
        error_type,
    } = parse_macro_input!(input as ApiEndpoint);

    let generated = quote! {
        pub struct #client_name {
            base_url: String,
            client: reqwest::Client,
        }

        impl #client_name {
            pub fn new(base_url: String) -> Self {
                Self {
                    base_url,
                    client: reqwest::Client::new(),
                }
            }

            pub async fn execute(
                &self,
                params: #params_type
            ) -> Result<#response_type, #error_type> {
                use reqwest::Method;
                use serde_json::Value;
                use std::convert::TryFrom;

                // Handle path parameter substitution
                let mut url = format!("{}{}", self.base_url, #path);
                let params_json = serde_json::to_value(&params)
                    .map_err(|e| std::convert::Into::into(e))?;

                if let Value::Object(obj) = &params_json {
                    for (key, value) in obj {
                        let pattern = format!("{{{}}}", key);
                        if url.contains(&pattern) {
                            url = url.replace(&pattern, &value.as_str().unwrap_or_default());
                        }
                    }
                }

                let method = Method::from_bytes(#method.as_bytes())
                    .map_err(|_| std::convert::Into::into(
                        reqwest::Error::from(std::io::Error::new(
                            std::io::ErrorKind::InvalidInput,
                            "Invalid HTTP method"
                        ))
                    ))?;

                let response = self.client
                    .request(method, &url)
                    .json(&params)
                    .send()
                    .await
                    .map_err(std::convert::Into::into)?;

                if !response.status().is_success() {
                    return Err(std::convert::Into::into(
                        response.error_for_status().unwrap_err()
                    ));
                }

                response.json::<#response_type>()
                    .await
                    .map_err(std::convert::Into::into)
            }
        }
    };

    generated.into()
}