1use 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#[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#[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#[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#[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#[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#[proc_macro_attribute]
170pub fn job(args: TokenStream, input: TokenStream) -> TokenStream {
171 job_impl(args, input)
172}
173
174#[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#[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}