Skip to main content

purwa_macros/
lib.rs

1//! Proc-macros for Purwa HTTP routing (`#[get]`, `#[resource]`, …).
2
3use proc_macro::TokenStream;
4use quote::{format_ident, quote};
5use syn::parse::{Parse, ParseStream};
6use syn::parse_macro_input;
7use syn::spanned::Spanned;
8use syn::{Attribute, Ident, ItemFn, ItemMod, ItemStruct, LitStr, Type};
9
10/// `#[auth(Backend)]` — require a logged-in session for handlers with **no** parameters.
11///
12/// Redirects to `/login` when unauthenticated. For handlers that need extractors, use
13/// [`purwa_auth::CurrentUser`] or [`purwa::auth::CurrentUser`] with the `auth` feature, or add
14/// [`purwa::auth::AuthSession`] manually.
15///
16/// **Requires** crate feature `purwa/auth`.
17#[proc_macro_attribute]
18pub fn auth(args: TokenStream, input: TokenStream) -> TokenStream {
19    auth_impl(args, input)
20}
21
22fn auth_impl(args: TokenStream, input: TokenStream) -> TokenStream {
23    struct AuthArgs {
24        backend: Type,
25    }
26
27    impl Parse for AuthArgs {
28        fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
29            Ok(AuthArgs {
30                backend: input.parse()?,
31            })
32        }
33    }
34
35    let AuthArgs { backend } = parse_macro_input!(args as AuthArgs);
36    let mut input_fn = parse_macro_input!(input as ItemFn);
37
38    if !input_fn.sig.inputs.is_empty() {
39        return syn::Error::new(
40            input_fn.sig.inputs.span(),
41            "#[auth(Backend)] only supports handlers with no parameters; use CurrentUser<Backend> or AuthSession<Backend>",
42        )
43        .to_compile_error()
44        .into();
45    }
46
47    input_fn.sig.output = syn::parse_quote! {
48        -> impl ::purwa::axum::response::IntoResponse
49    };
50
51    let param: syn::FnArg = syn::parse_quote! {
52        mut auth_session: ::purwa::auth::AuthSession<#backend>
53    };
54    input_fn.sig.inputs.insert(0, param);
55
56    let stmts = &input_fn.block.stmts;
57    input_fn.block = syn::parse_quote! {
58        {
59            use ::purwa::axum::response::IntoResponse;
60            if auth_session.user.is_none() {
61                return ::purwa::axum::response::Redirect::temporary("/login").into_response();
62            }
63            let __purwa_body = {
64                #(#stmts)*
65            };
66            __purwa_body.into_response()
67        }
68    };
69
70    quote! { #input_fn }.into()
71}
72
73/// `#[get("/path")] async fn name(...) -> ...`
74#[proc_macro_attribute]
75pub fn get(args: TokenStream, input: TokenStream) -> TokenStream {
76    route_method_macro(
77        args,
78        input,
79        quote! { ::purwa::axum::routing::get },
80        quote! { ::purwa::axum::http::Method::GET },
81    )
82}
83
84/// `#[post("/path")] async fn ...`
85#[proc_macro_attribute]
86pub fn post(args: TokenStream, input: TokenStream) -> TokenStream {
87    route_method_macro(
88        args,
89        input,
90        quote! { ::purwa::axum::routing::post },
91        quote! { ::purwa::axum::http::Method::POST },
92    )
93}
94
95/// `#[put("/path")] async fn ...`
96#[proc_macro_attribute]
97pub fn put(args: TokenStream, input: TokenStream) -> TokenStream {
98    route_method_macro(
99        args,
100        input,
101        quote! { ::purwa::axum::routing::put },
102        quote! { ::purwa::axum::http::Method::PUT },
103    )
104}
105
106/// `#[delete("/path")] async fn ...`
107#[proc_macro_attribute]
108pub fn delete(args: TokenStream, input: TokenStream) -> TokenStream {
109    route_method_macro(
110        args,
111        input,
112        quote! { ::purwa::axum::routing::delete },
113        quote! { ::purwa::axum::http::Method::DELETE },
114    )
115}
116
117fn route_method_macro(
118    args: TokenStream,
119    input: TokenStream,
120    method_router: proc_macro2::TokenStream,
121    method_expr: proc_macro2::TokenStream,
122) -> TokenStream {
123    let path = parse_macro_input!(args as LitStr);
124    if !path.value().starts_with('/') {
125        return syn::Error::new(path.span(), "route path must start with `/`")
126            .to_compile_error()
127            .into();
128    }
129
130    let input_fn = parse_macro_input!(input as ItemFn);
131    let fn_name = input_fn.sig.ident.clone();
132    let install_fn = format_ident!("__purwa_install_{}", fn_name);
133    let handler_label = format!(
134        "{}::{}",
135        std::env::var("CARGO_CRATE_NAME").unwrap_or_else(|_| "unknown".into()),
136        fn_name
137    );
138    let handler_label_static = LitStr::new(&handler_label, fn_name.span());
139
140    let expanded = quote! {
141        #input_fn
142
143        fn #install_fn(
144            router: ::purwa::axum::Router,
145        ) -> ::purwa::axum::Router {
146            router.route(#path, #method_router(#fn_name))
147        }
148
149        ::purwa::inventory::submit! {
150            ::purwa::routing::RegisteredRoute {
151                method: #method_expr,
152                path: #path,
153                handler_label: #handler_label_static,
154                install: ::core::option::Option::Some(#install_fn),
155            }
156        }
157    };
158
159    expanded.into()
160}
161
162/// `#[job]` — register a job payload + handler into `purwa-queue`'s inventory.
163///
164/// Apply it to a payload struct and implement an inherent async method:
165///
166/// - `impl MyJob { pub async fn perform(self, ctx: purwa_queue::JobContext) -> Result<(), String> { ... } }`
167///
168/// Optional args: `#[job(type = "send-email")]`.
169#[proc_macro_attribute]
170pub fn job(args: TokenStream, input: TokenStream) -> TokenStream {
171    job_impl(args, input)
172}
173
174/// `#[cron]` — register a scheduled enqueue into `purwa-queue`'s inventory.
175///
176/// Usage on a module item (any item; macro expands into an `inventory::submit!`):
177///
178/// ```ignore
179/// #[cron(
180///   name = "nightly",
181///   cron = "0 2 * * *",
182///   job = "send-email",
183///   payload = "{\"to\":\"a@b.com\"}"
184/// )]
185/// pub const _SCHEDULE: () = ();
186/// ```
187#[proc_macro_attribute]
188pub fn cron(args: TokenStream, input: TokenStream) -> TokenStream {
189    cron_impl(args, input)
190}
191
192fn cron_impl(args: TokenStream, input: TokenStream) -> TokenStream {
193    struct CronArgs {
194        name: LitStr,
195        cron: LitStr,
196        job: LitStr,
197        payload: LitStr,
198    }
199
200    impl Parse for CronArgs {
201        fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
202            fn parse_kv(input: ParseStream<'_>) -> syn::Result<(Ident, LitStr)> {
203                let key: Ident = input.parse()?;
204                input.parse::<syn::Token![=]>()?;
205                let v: LitStr = input.parse()?;
206                Ok((key, v))
207            }
208
209            let mut name: Option<LitStr> = None;
210            let mut cron: Option<LitStr> = None;
211            let mut job: Option<LitStr> = None;
212            let mut payload: Option<LitStr> = None;
213
214            while !input.is_empty() {
215                let (k, v) = parse_kv(input)?;
216                if k == "name" {
217                    name = Some(v);
218                } else if k == "cron" {
219                    cron = Some(v);
220                } else if k == "job" {
221                    job = Some(v);
222                } else if k == "payload" {
223                    payload = Some(v);
224                } else {
225                    return Err(syn::Error::new(k.span(), "unknown cron arg"));
226                }
227                if input.peek(syn::Token![,]) {
228                    input.parse::<syn::Token![,]>()?;
229                }
230            }
231
232            Ok(CronArgs {
233                name: name.ok_or_else(|| syn::Error::new(input.span(), "missing `name`"))?,
234                cron: cron.ok_or_else(|| syn::Error::new(input.span(), "missing `cron`"))?,
235                job: job.ok_or_else(|| syn::Error::new(input.span(), "missing `job`"))?,
236                payload: payload
237                    .ok_or_else(|| syn::Error::new(input.span(), "missing `payload`"))?,
238            })
239        }
240    }
241
242    let CronArgs {
243        name,
244        cron,
245        job,
246        payload,
247    } = parse_macro_input!(args as CronArgs);
248
249    let item: syn::Item = parse_macro_input!(input as syn::Item);
250
251    let expanded = quote! {
252        #item
253
254        ::purwa_queue::inventory::submit! {
255            ::purwa_queue::CronEntry {
256                name: #name,
257                cron: #cron,
258                job_type: #job,
259                payload_json: #payload,
260            }
261        }
262    };
263
264    expanded.into()
265}
266
267fn job_impl(args: TokenStream, input: TokenStream) -> TokenStream {
268    #[derive(Default)]
269    struct JobArgs {
270        ty: Option<LitStr>,
271    }
272
273    impl Parse for JobArgs {
274        fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
275            if input.is_empty() {
276                return Ok(JobArgs::default());
277            }
278            let key: Ident = input.parse()?;
279            if key != "type" {
280                return Err(syn::Error::new(key.span(), "expected `type = \"...\"`"));
281            }
282            input.parse::<syn::Token![=]>()?;
283            let v: LitStr = input.parse()?;
284            Ok(JobArgs { ty: Some(v) })
285        }
286    }
287
288    fn kebab_case(ident: &Ident) -> String {
289        let s = ident.to_string();
290        let mut out = String::new();
291        for (i, ch) in s.chars().enumerate() {
292            if ch.is_uppercase() {
293                if i != 0 {
294                    out.push('-');
295                }
296                for lc in ch.to_lowercase() {
297                    out.push(lc);
298                }
299            } else {
300                out.push(ch);
301            }
302        }
303        out
304    }
305
306    let JobArgs { ty } = parse_macro_input!(args as JobArgs);
307    let mut item = parse_macro_input!(input as ItemStruct);
308
309    item.attrs.retain(|a| !a.path().is_ident("job"));
310
311    let name = item.ident.clone();
312    let type_s = ty.unwrap_or_else(|| LitStr::new(&kebab_case(&name), name.span()));
313
314    let handler_fn = format_ident!("__purwa_job_handle_{}", name);
315
316    let expanded = quote! {
317        #item
318
319        impl ::purwa_queue::Job for #name {
320            const TYPE: &'static str = #type_s;
321        }
322
323        fn #handler_fn(
324            payload: ::serde_json::Value,
325            ctx: ::purwa_queue::JobContext,
326        ) -> ::purwa_queue::JobHandleFuture {
327            ::core::boxed::Box::pin(async move {
328                let job: #name = ::serde_json::from_value(payload).map_err(|e| e.to_string())?;
329                job.perform(ctx).await
330            })
331        }
332
333        ::purwa_queue::inventory::submit! {
334            ::purwa_queue::JobHandlerEntry {
335                job_type: <#name as ::purwa_queue::Job>::TYPE,
336                handle: #handler_fn,
337            }
338        }
339    };
340
341    expanded.into()
342}
343
344/// `#[resource("/prefix")] pub mod name { pub async fn index() ... pub async fn destroy() ... }`
345///
346/// Requires exactly these `pub async fn` names: `index`, `create`, `store`, `show`, `edit`,
347/// `update`, `destroy`.
348#[proc_macro_attribute]
349pub fn resource(args: TokenStream, input: TokenStream) -> TokenStream {
350    let prefix_lit = parse_macro_input!(args as LitStr);
351    let prefix = prefix_lit.value();
352    if !prefix.starts_with('/') {
353        return syn::Error::new(
354            prefix_lit.span(),
355            "resource path prefix must start with `/`",
356        )
357        .to_compile_error()
358        .into();
359    }
360
361    let mut module = parse_macro_input!(input as ItemMod);
362    module.attrs.retain(|a| !is_purwa_resource_attr(a));
363
364    let mod_ident = module.ident.clone();
365    let required = [
366        "index", "create", "store", "show", "edit", "update", "destroy",
367    ];
368    for name in required {
369        if !module_has_pub_async_fn(&module, name) {
370            return syn::Error::new(
371                module.span(),
372                format!(
373                    "`#[resource]` module `{}` must declare `pub async fn {}`",
374                    mod_ident, name
375                ),
376            )
377            .to_compile_error()
378            .into();
379        }
380    }
381
382    let base = prefix.trim_end_matches('/').to_string();
383    let path_root = LitStr::new(&base, prefix_lit.span());
384    let path_create = LitStr::new(&format!("{base}/create"), prefix_lit.span());
385    let path_id = LitStr::new(&format!("{base}/{{id}}"), prefix_lit.span());
386    let path_edit = LitStr::new(&format!("{base}/{{id}}/edit"), prefix_lit.span());
387
388    let bundle_root = format_ident!("__purwa_res_{}_bundle_root", mod_ident);
389    let bundle_id = format_ident!("__purwa_res_{}_bundle_id", mod_ident);
390    let install_create = format_ident!("__purwa_res_{}_create", mod_ident);
391    let install_edit = format_ident!("__purwa_res_{}_edit", mod_ident);
392
393    let crate_name = std::env::var("CARGO_CRATE_NAME").unwrap_or_else(|_| "unknown".into());
394    let sp = prefix_lit.span();
395    let lbl = |suffix: &str| LitStr::new(&format!("{crate_name}::{mod_ident}::{suffix}"), sp);
396    let l_index = lbl("index");
397    let l_store = lbl("store");
398    let l_create = lbl("create");
399    let l_show = lbl("show");
400    let l_edit = lbl("edit");
401    let l_update = lbl("update");
402    let l_destroy = lbl("destroy");
403
404    quote! {
405        #module
406
407        fn #bundle_root(router: ::purwa::axum::Router) -> ::purwa::axum::Router {
408            router.route(
409                #path_root,
410                ::purwa::axum::routing::get(#mod_ident::index).post(#mod_ident::store),
411            )
412        }
413
414        fn #install_create(router: ::purwa::axum::Router) -> ::purwa::axum::Router {
415            router.route(#path_create, ::purwa::axum::routing::get(#mod_ident::create))
416        }
417
418        fn #bundle_id(router: ::purwa::axum::Router) -> ::purwa::axum::Router {
419            router.route(
420                #path_id,
421                ::purwa::axum::routing::get(#mod_ident::show)
422                    .put(#mod_ident::update)
423                    .delete(#mod_ident::destroy),
424            )
425        }
426
427        fn #install_edit(router: ::purwa::axum::Router) -> ::purwa::axum::Router {
428            router.route(#path_edit, ::purwa::axum::routing::get(#mod_ident::edit))
429        }
430
431        ::purwa::inventory::submit! {
432            ::purwa::routing::RegisteredRoute {
433                method: ::purwa::axum::http::Method::GET,
434                path: #path_root,
435                handler_label: #l_index,
436                install: ::core::option::Option::Some(#bundle_root),
437            }
438        }
439        ::purwa::inventory::submit! {
440            ::purwa::routing::RegisteredRoute {
441                method: ::purwa::axum::http::Method::POST,
442                path: #path_root,
443                handler_label: #l_store,
444                install: ::core::option::Option::None,
445            }
446        }
447        ::purwa::inventory::submit! {
448            ::purwa::routing::RegisteredRoute {
449                method: ::purwa::axum::http::Method::GET,
450                path: #path_create,
451                handler_label: #l_create,
452                install: ::core::option::Option::Some(#install_create),
453            }
454        }
455        ::purwa::inventory::submit! {
456            ::purwa::routing::RegisteredRoute {
457                method: ::purwa::axum::http::Method::GET,
458                path: #path_id,
459                handler_label: #l_show,
460                install: ::core::option::Option::Some(#bundle_id),
461            }
462        }
463        ::purwa::inventory::submit! {
464            ::purwa::routing::RegisteredRoute {
465                method: ::purwa::axum::http::Method::GET,
466                path: #path_edit,
467                handler_label: #l_edit,
468                install: ::core::option::Option::Some(#install_edit),
469            }
470        }
471        ::purwa::inventory::submit! {
472            ::purwa::routing::RegisteredRoute {
473                method: ::purwa::axum::http::Method::PUT,
474                path: #path_id,
475                handler_label: #l_update,
476                install: ::core::option::Option::None,
477            }
478        }
479        ::purwa::inventory::submit! {
480            ::purwa::routing::RegisteredRoute {
481                method: ::purwa::axum::http::Method::DELETE,
482                path: #path_id,
483                handler_label: #l_destroy,
484                install: ::core::option::Option::None,
485            }
486        }
487    }
488    .into()
489}
490
491fn is_purwa_resource_attr(attr: &Attribute) -> bool {
492    attr.path().is_ident("resource")
493}
494
495fn module_has_pub_async_fn(module: &ItemMod, name: &str) -> bool {
496    let Some((_, items)) = &module.content else {
497        return false;
498    };
499    let want = Ident::new(name, proc_macro2::Span::call_site());
500    for item in items {
501        if let syn::Item::Fn(f) = item
502            && f.sig.ident == want
503        {
504            let is_pub = matches!(f.vis, syn::Visibility::Public(_));
505            let is_async = f.sig.asyncness.is_some();
506            return is_pub && is_async;
507        }
508    }
509    false
510}