ftl_sdk_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{FnArg, ItemFn, parse_macro_input};
4
5/// Define multiple tools in a single component.
6///
7/// This macro generates the complete HTTP handler with routing for your tools.
8/// The macro automatically handles JSON serialization and HTTP routing.
9///
10/// This macro allows you to define all your tools in one place, automatically
11/// generating the HTTP handler and metadata for each tool.
12///
13/// # Example
14/// ```ignore
15/// tools! {
16///     /// Echo back the input message
17///     fn echo(input: EchoInput) -> ToolResponse {
18///         ToolResponse::text(format!("Echo: {}", input.message))
19///     }
20///
21///     /// Reverse the input text
22///     fn reverse(input: ReverseInput) -> ToolResponse {
23///         ToolResponse::text(input.text.chars().rev().collect::<String>())
24///     }
25/// }
26/// ```
27#[proc_macro]
28pub fn tools(input: TokenStream) -> TokenStream {
29    let tools = parse_macro_input!(input as ToolsDefinition);
30
31    // Collect all tool functions
32    let tool_fns: Vec<_> = tools.functions.iter().collect();
33
34    // Generate metadata for each tool
35    let metadata_items: Vec<_> = tool_fns.iter().map(|func| {
36        let name = &func.sig.ident;
37        let name_str = name.to_string();
38
39        // Extract doc comment
40        let description = extract_doc_comment(&func.attrs)
41            .map(|d| quote!(Some(#d.to_string())))
42            .unwrap_or(quote!(None));
43
44        // Get input type
45        let input_type = match func.sig.inputs.first() {
46            Some(FnArg::Typed(pat_type)) => &pat_type.ty,
47            _ => panic!("Tool function must have exactly one typed argument"),
48        };
49
50        quote! {
51            ::ftl_sdk::ToolMetadata {
52                name: #name_str.to_string(),
53                title: None,
54                description: #description,
55                input_schema: ::serde_json::to_value(::schemars::schema_for!(#input_type)).unwrap(),
56                output_schema: None,
57                annotations: None,
58                meta: None,
59            }
60        }
61    }).collect();
62
63    // Generate routing cases for POST requests
64    let routing_cases: Vec<_> = tool_fns.iter().map(|func| {
65        let name = &func.sig.ident;
66        let name_str = name.to_string();
67        let is_async = func.sig.asyncness.is_some();
68
69        // Get input type
70        let input_type = match func.sig.inputs.first() {
71            Some(FnArg::Typed(pat_type)) => &pat_type.ty,
72            _ => panic!("Tool function must have exactly one typed argument"),
73        };
74
75        let fn_call = if is_async {
76            quote!(#name(input).await)
77        } else {
78            quote!(#name(input))
79        };
80
81        quote! {
82            #name_str => {
83                match ::serde_json::from_slice::<#input_type>(body) {
84                    Ok(input) => {
85                        let response = #fn_call;
86                        match ::serde_json::to_vec(&response) {
87                            Ok(body) => Response::builder()
88                                .status(200)
89                                .header("Content-Type", "application/json")
90                                .body(body)
91                                .build(),
92                            Err(e) => {
93                                let error_response = ::ftl_sdk::ToolResponse::error(
94                                    format!("Failed to serialize response: {}", e)
95                                );
96                                Response::builder()
97                                    .status(500)
98                                    .header("Content-Type", "application/json")
99                                    .body(::serde_json::to_vec(&error_response).unwrap_or_default())
100                                    .build()
101                            }
102                        }
103                    }
104                    Err(e) => {
105                        let error_response = ::ftl_sdk::ToolResponse::error(
106                            format!("Invalid request body: {}", e)
107                        );
108                        Response::builder()
109                            .status(400)
110                            .header("Content-Type", "application/json")
111                            .body(::serde_json::to_vec(&error_response).unwrap_or_default())
112                            .build()
113                    }
114                }
115            }
116        }
117    }).collect();
118
119    let output = quote! {
120        // Define all tool functions
121        #(#tool_fns)*
122
123        // Generate the HTTP component handler
124        #[::spin_sdk::http_component]
125        async fn handle_tool_component(req: ::spin_sdk::http::Request) -> ::spin_sdk::http::Response {
126            use ::spin_sdk::http::{Method, Response};
127
128            let path = req.path();
129
130            match req.method() {
131                &Method::Get if path == "/" => {
132                    // Return metadata for all tools
133                    let tools = vec![
134                        #(#metadata_items),*
135                    ];
136
137                    match ::serde_json::to_vec(&tools) {
138                        Ok(body) => Response::builder()
139                            .status(200)
140                            .header("Content-Type", "application/json")
141                            .body(body)
142                            .build(),
143                        Err(e) => Response::builder()
144                            .status(500)
145                            .body(format!("Failed to serialize metadata: {}", e))
146                            .build()
147                    }
148                }
149                &Method::Post => {
150                    // Get the tool name from the path
151                    let tool_name = path.trim_start_matches('/');
152                    let body = req.body();
153
154                    match tool_name {
155                        #(#routing_cases)*
156                        _ => {
157                            let error_response = ::ftl_sdk::ToolResponse::error(
158                                format!("Tool '{}' not found", tool_name)
159                            );
160                            Response::builder()
161                                .status(404)
162                                .header("Content-Type", "application/json")
163                                .body(::serde_json::to_vec(&error_response).unwrap_or_default())
164                                .build()
165                        }
166                    }
167                }
168                _ => Response::builder()
169                    .status(405)
170                    .header("Allow", "GET, POST")
171                    .body("Method not allowed")
172                    .build()
173            }
174        }
175    };
176
177    output.into()
178}
179
180// Parse multiple function definitions
181struct ToolsDefinition {
182    functions: Vec<ItemFn>,
183}
184
185impl syn::parse::Parse for ToolsDefinition {
186    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
187        let mut functions = Vec::new();
188
189        while !input.is_empty() {
190            functions.push(input.parse::<ItemFn>()?);
191        }
192
193        if functions.is_empty() {
194            return Err(syn::Error::new(
195                input.span(),
196                "At least one tool function must be defined",
197            ));
198        }
199
200        Ok(ToolsDefinition { functions })
201    }
202}
203
204// Extract the first line of doc comments from attributes
205fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
206    attrs
207        .iter()
208        .filter_map(|attr| {
209            if attr.path().is_ident("doc")
210                && let syn::Meta::NameValue(nv) = &attr.meta
211                && let syn::Expr::Lit(lit) = &nv.value
212                && let syn::Lit::Str(s) = &lit.lit
213            {
214                let doc = s.value();
215                // Trim leading space that rustdoc adds
216                return Some(doc.trim_start_matches(' ').to_string());
217            }
218            None
219        })
220        .next()
221}