http_provider_macro/lib.rs
1//! # HTTP Provider Macro
2//!
3//! A procedural macro for generating HTTP client providers with compile-time endpoint definitions.
4//! This macro eliminates boilerplate code when creating HTTP clients by automatically generating
5//! methods for your API endpoints.
6//!
7//! ## Features
8//!
9//! - **Zero runtime overhead** - All HTTP client code is generated at compile time
10//! - **Automatic method generation** - Function names auto-generated from HTTP method and path
11//! - **Type-safe requests/responses** - Full Rust type checking for all parameters
12//! - **Full HTTP method support** - GET, POST, PUT, DELETE
13//! - **Path parameters** - Dynamic URL path substitution with `{param}` syntax
14//! - **Query parameters** - Automatic query string serialization
15//! - **Custom headers** - Per-request header support
16//! - **Async/await** - Built on reqwest with full async support
17//! - **Configurable timeouts** - Per-client timeout configuration
18//!
19//! ## Quick Start
20//!
21//! ```rust
22//! use http_provider_macro::http_provider;
23//! use serde::{Deserialize, Serialize};
24//!
25//! #[derive(Serialize, Deserialize, Debug)]
26//! struct User {
27//! id: u32,
28//! name: String,
29//! }
30//!
31//! #[derive(Serialize)]
32//! struct CreateUser {
33//! name: String,
34//! }
35//!
36//! // Define your HTTP provider
37//! http_provider!(
38//! UserApi,
39//! {
40//! {
41//! path: "/users",
42//! method: GET,
43//! res: Vec<User>,
44//! },
45//! {
46//! path: "/users",
47//! method: POST,
48//! req: CreateUser,
49//! res: User,
50//! }
51//! }
52//! );
53//!
54//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
55//! let base_url = reqwest::Url::parse("https://api.example.com")?;
56//! let client = UserApi::new(base_url, 30);
57//!
58//! // Auto-generated methods
59//! let users = client.get_users().await?;
60//! let new_user = client.post_users(&CreateUser {
61//! name: "John".to_string()
62//! }).await?;
63//! # Ok(())
64//! # }
65//! ```
66//!
67//! ## Endpoint Configuration
68//!
69//! Each endpoint is defined within braces with these fields:
70//!
71//! ### Required Fields
72//! - `path`: API endpoint path (string literal)
73//! - `method`: HTTP method (GET, POST, PUT, DELETE)
74//! - `res`: Response type implementing `serde::Deserialize`
75//!
76//! ### Optional Fields
77//! - `fn_name`: Custom function name (auto-generated if omitted)
78//! - `req`: Request body type implementing `serde::Serialize`
79//! - `headers`: Header type (typically `reqwest::header::HeaderMap`)
80//! - `query_params`: Query parameters type implementing `serde::Serialize`
81//! - `path_params`: Path parameters type with fields matching `{param}` in path
82//!
83//! ## Examples
84//!
85//! ### Path Parameters
86//!
87//! ```rust
88//! # use http_provider_macro::http_provider;
89//! # use serde::{Deserialize, Serialize};
90//! #[derive(Serialize)]
91//! struct UserPath {
92//! id: u32,
93//! }
94//!
95//! #[derive(Deserialize)]
96//! struct User {
97//! id: u32,
98//! name: String,
99//! }
100//!
101//! http_provider!(
102//! UserApi,
103//! {
104//! {
105//! path: "/users/{id}",
106//! method: GET,
107//! path_params: UserPath,
108//! res: User,
109//! }
110//! }
111//! );
112//! ```
113//!
114//! ### Query Parameters and Headers
115//!
116//! ```rust
117//! # use http_provider_macro::http_provider;
118//! # use serde::{Deserialize, Serialize};
119//! # use reqwest::header::HeaderMap;
120//! #[derive(Serialize)]
121//! struct SearchQuery {
122//! q: String,
123//! limit: u32,
124//! }
125//!
126//! #[derive(Deserialize)]
127//! struct SearchResults {
128//! results: Vec<String>,
129//! }
130//!
131//! http_provider!(
132//! SearchApi,
133//! {
134//! {
135//! path: "/search",
136//! method: GET,
137//! fn_name: search_items,
138//! query_params: SearchQuery,
139//! headers: HeaderMap,
140//! res: SearchResults,
141//! }
142//! }
143//! );
144//! ```
145
146extern crate proc_macro;
147
148use crate::{
149 error::{MacroError, MacroResult},
150 input::{EndpointDef, HttpMethod, HttpProviderInput},
151};
152use heck::ToSnakeCase;
153use proc_macro2::Span;
154use quote::quote;
155use regex::Regex;
156use syn::{parse_macro_input, spanned::Spanned, Ident};
157
158mod error;
159mod input;
160
161/// Generates an HTTP client provider struct with methods for each defined endpoint.
162///
163/// This macro takes a struct name and a list of endpoint definitions, generating
164/// a complete HTTP client with methods for each endpoint.
165///
166/// # Syntax
167///
168/// ```text
169/// http_provider!(
170/// StructName,
171/// {
172/// {
173/// path: "/endpoint/path",
174/// method: HTTP_METHOD,
175/// [fn_name: custom_function_name,]
176/// [req: RequestType,]
177/// res: ResponseType,
178/// [headers: HeaderType,]
179/// [query_params: QueryType,]
180/// [path_params: PathParamsType,]
181/// },
182/// // ... more endpoints
183/// }
184/// );
185/// ```
186///
187/// # Generated Structure
188///
189/// The macro generates:
190/// - A struct with `url`, `client`, and `timeout` fields
191/// - A `new(url: reqwest::Url, timeout: u64)` constructor
192/// - One async method per endpoint definition
193///
194/// # Method Naming
195///
196/// When `fn_name` is not provided, method names are auto-generated as:
197/// `{method}_{path}` where path separators become underscores.
198///
199/// # Examples
200///
201/// ```rust
202/// use http_provider_macro::http_provider;
203/// use serde::{Deserialize, Serialize};
204///
205/// #[derive(Serialize, Deserialize)]
206/// struct User {
207/// id: u32,
208/// name: String,
209/// }
210///
211/// http_provider!(
212/// UserClient,
213/// {
214/// {
215/// path: "/users",
216/// method: GET,
217/// res: Vec<User>,
218/// },
219/// {
220/// path: "/users/{id}",
221/// method: GET,
222/// path_params: UserPath,
223/// res: User,
224/// }
225/// }
226/// );
227///
228/// #[derive(Serialize)]
229/// struct UserPath {
230/// id: u32,
231/// }
232/// ```
233#[proc_macro]
234pub fn http_provider(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
235 let parsed = parse_macro_input!(input as HttpProviderInput);
236
237 let mut expander = HttpProviderMacroExpander::new();
238
239 match expander.expand(parsed) {
240 Ok(tokens) => tokens.into(),
241 Err(err) => err.to_compile_error().into(),
242 }
243}
244
245/// Main expander that generates the HTTP provider struct and its methods.
246struct HttpProviderMacroExpander;
247
248impl HttpProviderMacroExpander {
249 fn new() -> Self {
250 Self
251 }
252
253 /// Expands the macro input into a complete HTTP provider implementation.
254 fn expand(&mut self, input: HttpProviderInput) -> MacroResult<proc_macro2::TokenStream> {
255 if input.endpoints.is_empty() {
256 return Err(MacroError::Custom {
257 message: "No endpoints defined".to_string(),
258 span: input.struct_name.span(),
259 });
260 }
261
262 let struct_name = input.struct_name;
263
264 let methods: Vec<proc_macro2::TokenStream> = input
265 .endpoints
266 .iter()
267 .filter(|endpoint| endpoint.trait_impl.is_none())
268 .map(|endpoint| self.expand_method(endpoint))
269 .collect::<Result<_, _>>()?;
270
271 let trait_methods: Vec<proc_macro2::TokenStream> = input
272 .endpoints
273 .iter()
274 .filter(|endpoint| endpoint.trait_impl.is_some())
275 .map(|endpoint| self.expand_trait_method(&struct_name, endpoint))
276 .collect::<Result<_, _>>()?;
277
278 Ok(quote! {
279 pub struct #struct_name {
280 url: reqwest::Url,
281 client: reqwest::Client,
282 timeout: std::time::Duration,
283 }
284
285 impl #struct_name {
286 /// Creates a new HTTP provider instance.
287 ///
288 /// # Arguments
289 /// * `url` - Base URL for all requests
290 /// * `timeout` - Request timeout in milliseconds
291 pub fn new(url: reqwest::Url, timeout: u64) -> Self {
292 let client = reqwest::Client::new();
293 let timeout = std::time::Duration::from_millis(timeout);
294 Self { url, client, timeout }
295 }
296
297 #(#methods)*
298 }
299
300 #(#trait_methods)*
301 })
302 }
303
304 fn expand_trait_method(
305 &self,
306 struct_name: &Ident,
307 endpoint: &EndpointDef,
308 ) -> MacroResult<proc_macro2::TokenStream> {
309 let method = self.expand_method(endpoint)?;
310
311 let trait_impl = endpoint
312 .trait_impl
313 .as_ref()
314 .ok_or_else(|| MacroError::Custom {
315 message: "Trait impl is not configured".to_string(),
316 span: method.span(),
317 })?;
318
319 Ok(quote! {
320 impl #trait_impl for #struct_name {
321 #method
322 }
323 })
324 }
325
326 /// Generates a single HTTP method for an endpoint definition.
327 fn expand_method(&self, endpoint: &EndpointDef) -> MacroResult<proc_macro2::TokenStream> {
328 let method_expander = MethodExpander::new(endpoint);
329
330 let fn_signature = method_expander.expand_fn_signature();
331 let url_construction = method_expander.build_url_construction();
332 let request_building = method_expander.build_request();
333 let response_handling = method_expander.build_response_handling();
334
335 Ok(quote! {
336 #fn_signature {
337 #url_construction
338 #request_building
339 #response_handling
340 }
341 })
342 }
343}
344/// Handles the expansion of individual HTTP method implementations
345struct MethodExpander<'a> {
346 def: &'a EndpointDef,
347}
348
349impl<'a> MethodExpander<'a> {
350 fn new(def: &'a EndpointDef) -> Self {
351 Self { def }
352 }
353
354 /// Generates the function signature for an endpoint method.
355 fn expand_fn_signature(&self) -> proc_macro2::TokenStream {
356 let method = &self.def.method;
357
358 // Handle the function name logic based on whether path is provided
359 let fn_name = if let Some(ref name) = self.def.fn_name {
360 name.clone()
361 } else {
362 let method_str = format!("{:?}", method).to_lowercase();
363
364 // Handle the case where the path is optional
365 let auto_name = if let Some(ref path) = self.def.path {
366 let path_str = path.value().trim_start_matches('/').replace("/", "_");
367 format!("{}_{}", method_str, path_str).to_snake_case()
368 } else {
369 format!("{}_no_path", method_str).to_snake_case() // Default function name if no path
370 };
371
372 Ident::new(
373 &auto_name,
374 self.def
375 .path
376 .as_ref()
377 .map_or_else(Span::call_site, |p| p.span()),
378 )
379 };
380
381 let res = &self.def.res;
382
383 let mut params = vec![];
384
385 if let Some(path_params) = &self.def.path_params {
386 params.push(quote! { path_params: &#path_params });
387 }
388 if let Some(body) = &self.def.req {
389 params.push(quote! { body: &#body });
390 }
391 if let Some(headers) = &self.def.headers {
392 params.push(quote! { headers: #headers });
393 }
394 if let Some(query_params) = &self.def.query_params {
395 params.push(quote! { query_params: &#query_params });
396 }
397
398 // Determine if this is for a trait implementation
399 let is_trait_impl = self.def.trait_impl.is_some();
400 if is_trait_impl {
401 quote! {
402 async fn #fn_name(&self, #(#params),*) -> Result<#res,String>
403 }
404 } else {
405 quote! {
406 pub async fn #fn_name(&self, #(#params),*) -> Result<#res, String>
407 }
408 }
409 }
410
411 /// Generates URL construction logic, handling path parameter substitution.
412 fn build_url_construction(&self) -> proc_macro2::TokenStream {
413 // If path is None, we just use the base URL as is.
414 let path = if let Some(ref path) = self.def.path {
415 path.value()
416 } else {
417 // If no path, just use the URL as is
418 return quote! {
419 let url = self.url.clone(); // Use the base URL as is
420 };
421 };
422
423 if self.def.path_params.is_some() {
424 let re = Regex::new(r"\{([a-zA-Z0-9_]+)\}").unwrap();
425 let mut replacements = Vec::new();
426
427 for cap in re.captures_iter(&path) {
428 let param_name = &cap[1];
429 let ident = Ident::new(param_name, proc_macro2::Span::call_site());
430 replacements.push(quote! {
431 path = path.replace(concat!("{", #param_name, "}"), &path_params.#ident.to_string());
432 });
433 }
434
435 quote! {
436 let mut path = #path.to_string();
437 #(#replacements)*
438 let url = self.url.join(&path)
439 .map_err(|e| format!("Failed to construct URL: {}", e))?;
440 }
441 } else {
442 quote! {
443 let url = self.url.join(#path)
444 .map_err(|e| format!("Failed to construct URL: {}", e))?;
445 }
446 }
447 }
448
449 /// Generates request building logic including body, headers, and query parameters
450 fn build_request(&self) -> proc_macro2::TokenStream {
451 let method_call = match self.def.method {
452 HttpMethod::GET => quote! { self.client.get(url) },
453 HttpMethod::POST => quote! { self.client.post(url) },
454 HttpMethod::PUT => quote! { self.client.put(url) },
455 HttpMethod::DELETE => quote! { self.client.delete(url) },
456 };
457
458 let mut request_modifications = Vec::new();
459
460 // Add body handling
461 if self.def.req.is_some() {
462 request_modifications.push(quote! {
463 request = request.json(body);
464 });
465 }
466
467 if self.def.query_params.is_some() {
468 request_modifications.push(quote! {
469 request = request.query(query_params);
470 });
471 }
472
473 // Add headers
474 if self.def.headers.is_some() {
475 request_modifications.push(quote! {
476 let request = request.headers(headers);
477 });
478 }
479
480 quote! {
481 let mut request = #method_call;
482 #(#request_modifications)*
483 }
484 }
485
486 /// Generates response handling logic.
487 fn build_response_handling(&self) -> proc_macro2::TokenStream {
488 let res = &self.def.res;
489
490 quote! {
491 let response = request
492 .send()
493 .await
494 .map_err(|e| format!("Request failed: {}", e))?;
495
496 let status = response.status();
497 if !status.is_success() {
498 return Err(format!("HTTP request failed with status {}: {}",
499 status.as_u16(),
500 status.canonical_reason().unwrap_or("Unknown error")
501 ).into());
502 }
503
504 let result: #res = response
505 .json()
506 .await
507 .map_err(|e| format!("Failed to deserialize response: {}", e))?;
508
509 Ok(result)
510 }
511 }
512}