Skip to main content

rorpc_parse/codegen/
orpc.rs

1//! Code generation for the `#[rorpc(method, path)]` attribute macro.
2//!
3//! Parses the attribute arguments, analyses the handler signature, and emits:
4//! - The original function unchanged
5//! - An `inventory::submit!` for `HandlerMetadata`
6//! - An `inventory::submit!` for `HandlerRegistration` (Axum router factory)
7//! - `inventory::submit!` blocks for `SchemaRegistration` fallback schemas
8
9use proc_macro2::TokenStream;
10use quote::quote;
11use syn::{
12    Expr, ExprLit, ItemFn, Lit, MetaNameValue, Token,
13    parse::{Parse, ParseStream},
14    punctuated::Punctuated,
15};
16
17use crate::{
18    errors::{Error, Result, type_display},
19    functions::extract_handler_signature,
20    types::{JSON, QUERY, RESULT, innermost_custom_type, is_primitive, try_extract_wrapper},
21};
22
23// ---------------------------------------------------------------------------
24// Attribute name constants — centralized for easy renaming
25// ---------------------------------------------------------------------------
26
27const ATTR_METHOD: &str = "method";
28const ATTR_PATH: &str = "path";
29const ATTR_DATA: &str = "data";
30
31// ---------------------------------------------------------------------------
32// OrpcArgs — parsed from #[orpc(method = "...", path = "...", data = TypePath)]
33// ---------------------------------------------------------------------------
34
35/// Parsed arguments for the `#[orpc(...)]` attribute.
36pub struct OrpcArgs {
37    pub method: String,
38    pub path: String,
39    pub stream_event: Option<String>,
40}
41
42// ---------------------------------------------------------------------------
43// MethodShorthandArgs — parsed from #[orpc::get("/path")] or #[orpc::post("/path", data = "Type")]
44// ---------------------------------------------------------------------------
45
46/// Parsed arguments for method-specific shorthand macros like `#[orpc::get("/path")]`.
47///
48/// Syntax: `#[orpc::get("/path")]` or `#[orpc::post("/path", data = "StreamEvent")]`
49pub struct MethodShorthandArgs {
50    pub path: String,
51    pub data: Option<String>,
52}
53
54impl Parse for MethodShorthandArgs {
55    fn parse(input: ParseStream) -> syn::Result<Self> {
56        // First token must be a string literal (the path)
57        let path_lit: syn::LitStr = input.parse()?;
58        let path = path_lit.value();
59
60        // Optional: comma + data = "Type"
61        let mut data = None;
62
63        if input.peek(Token![,]) {
64            input.parse::<Token![,]>()?;
65
66            let pairs = Punctuated::<MetaNameValue, Token![,]>::parse_terminated(input)?;
67
68            for pair in &pairs {
69                let key = pair
70                    .path
71                    .get_ident()
72                    .map(|i| i.to_string())
73                    .unwrap_or_default();
74
75                let span = pair
76                    .path
77                    .get_ident()
78                    .map(|i| i.span())
79                    .unwrap_or_else(proc_macro2::Span::call_site);
80
81                match key.as_str() {
82                    ATTR_DATA => match &pair.value {
83                        Expr::Lit(ExprLit {
84                            lit: Lit::Str(s), ..
85                        }) => {
86                            data = Some(s.value());
87                        }
88                        Expr::Path(expr_path) => {
89                            let type_path = syn::TypePath {
90                                attrs: vec![],
91                                qself: expr_path.qself.clone(),
92                                path: expr_path.path.clone(),
93                            };
94                            data = Some(type_display(&syn::Type::Path(type_path)));
95                        }
96                        _ => {
97                            return Err(syn::Error::new(
98                                span,
99                                format!(
100                                    "{} must be a string literal (\"StreamEvent\") or type path (StreamEvent)",
101                                    ATTR_DATA
102                                ),
103                            ));
104                        }
105                    },
106                    _ => {
107                        return Err(syn::Error::new(
108                            span,
109                            Error::unknown_key(span, &key, &[ATTR_DATA]).to_string(),
110                        ));
111                    }
112                }
113            }
114        }
115
116        Ok(MethodShorthandArgs { path, data })
117    }
118}
119
120/// Convert method shorthand args to standard OrpcArgs.
121///
122/// This allows method-specific macros like `#[orpc::get("/path")]` to reuse
123/// all the existing codegen logic without duplication.
124impl MethodShorthandArgs {
125    pub fn into_orpc_args(self, method: &str) -> OrpcArgs {
126        OrpcArgs {
127            method: method.to_uppercase(),
128            path: self.path,
129            stream_event: self.data,
130        }
131    }
132}
133
134const VALID_KEYS: &[&str] = &[ATTR_METHOD, ATTR_PATH, ATTR_DATA];
135
136impl Parse for OrpcArgs {
137    fn parse(input: ParseStream) -> syn::Result<Self> {
138        let pairs = Punctuated::<MetaNameValue, Token![,]>::parse_terminated(input)?;
139
140        let mut method = None;
141        let mut path = None;
142        let mut stream_event = None;
143
144        for pair in &pairs {
145            let key = pair
146                .path
147                .get_ident()
148                .map(|i| i.to_string())
149                .unwrap_or_default();
150
151            let span = pair
152                .path
153                .get_ident()
154                .map(|i| i.span())
155                .unwrap_or_else(proc_macro2::Span::call_site);
156
157            match key.as_str() {
158                ATTR_METHOD => {
159                    if let Expr::Lit(ExprLit {
160                        lit: Lit::Str(s), ..
161                    }) = &pair.value
162                    {
163                        method = Some(s.value().to_uppercase());
164                    } else {
165                        return Err(syn::Error::new(
166                            span,
167                            Error::invalid_attr_value(
168                                span,
169                                &key,
170                                "a string literal",
171                                "non-string expression",
172                            )
173                            .to_string(),
174                        ));
175                    }
176                }
177                ATTR_PATH => {
178                    if let Expr::Lit(ExprLit {
179                        lit: Lit::Str(s), ..
180                    }) = &pair.value
181                    {
182                        path = Some(s.value());
183                    } else {
184                        return Err(syn::Error::new(
185                            span,
186                            Error::invalid_attr_value(
187                                span,
188                                &key,
189                                "a string literal",
190                                "non-string expression",
191                            )
192                            .to_string(),
193                        ));
194                    }
195                }
196                ATTR_DATA => {
197                    match &pair.value {
198                        // String literal: data = "StreamEvent" or data = "module::StreamEvent"
199                        // Preferred syntax for IDE support
200                        Expr::Lit(ExprLit {
201                            lit: Lit::Str(s), ..
202                        }) => {
203                            stream_event = Some(s.value());
204                        }
205                        // Type path: data = StreamEvent (backward compatibility)
206                        Expr::Path(expr_path) => {
207                            let type_path = syn::TypePath {
208                                attrs: vec![],
209                                qself: expr_path.qself.clone(),
210                                path: expr_path.path.clone(),
211                            };
212                            stream_event = Some(type_display(&syn::Type::Path(type_path)));
213                        }
214                        _ => {
215                            return Err(syn::Error::new(
216                                span,
217                                format!(
218                                    "{} must be a string literal (\"StreamEvent\") or type path (StreamEvent)",
219                                    ATTR_DATA
220                                ),
221                            ));
222                        }
223                    }
224                }
225                _ => {
226                    return Err(syn::Error::new(
227                        span,
228                        Error::unknown_key(span, &key, VALID_KEYS).to_string(),
229                    ));
230                }
231            }
232        }
233
234        let method = method.ok_or_else(|| {
235            syn::Error::new(
236                proc_macro2::Span::call_site(),
237                Error::missing_required_attr(
238                    proc_macro2::Span::call_site(),
239                    ATTR_METHOD,
240                    "add `method = \"GET\"` to #[orpc]",
241                )
242                .to_string(),
243            )
244        })?;
245
246        let path = path.ok_or_else(|| {
247            syn::Error::new(
248                proc_macro2::Span::call_site(),
249                Error::missing_required_attr(
250                    proc_macro2::Span::call_site(),
251                    ATTR_PATH,
252                    "add `path = \"/your/route\"` to #[orpc]",
253                )
254                .to_string(),
255            )
256        })?;
257
258        Ok(OrpcArgs {
259            method,
260            path,
261            stream_event,
262        })
263    }
264}
265
266// ---------------------------------------------------------------------------
267// expand_orpc
268// ---------------------------------------------------------------------------
269
270/// Generate the full expansion for `#[rorpc::route(method, path)]` or shorthand macros.
271///
272/// Returns the original function unchanged plus all inventory registrations.
273pub fn expand_orpc(args: OrpcArgs, func: ItemFn) -> TokenStream {
274    match try_expand_orpc(args, func) {
275        Ok(ts) => ts,
276        Err(e) => e.to_compile_error(),
277    }
278}
279
280fn try_expand_orpc(args: OrpcArgs, func: ItemFn) -> Result<TokenStream> {
281    let sig = extract_handler_signature(&func)?;
282
283    let fn_name = &func.sig.ident;
284    let fn_name_str = sig.fn_name.as_str();
285    let method = &args.method;
286    let path = &args.path;
287
288    let output_type_str = type_display(&sig.output_type);
289
290    let error_type_token = match &sig.error_type {
291        Some(ty) => {
292            let s = type_display(ty);
293            quote! { Some(#s) }
294        }
295        None => quote! { None },
296    };
297
298    let stream_event_token = match &args.stream_event {
299        Some(type_name) => {
300            let s = type_name.as_str();
301            quote! { Some(#s) }
302        }
303        None => quote! { None },
304    };
305
306    let input_type_str = match &sig.input_type {
307        Some(ty) => type_display(ty),
308        None => "()".to_string(),
309    };
310
311    let query_type_token = match &sig.query_type {
312        Some(ty) => {
313            let s = type_display(ty);
314            quote! { Some(#s) }
315        }
316        None => quote! { None },
317    };
318
319    // Encode path param types as comma-separated string: "i32,String"
320    // Order matches the path template param order (declaration order in signature)
321    let path_param_types_str = sig
322        .path_params
323        .iter()
324        .map(|(_, ty)| type_display(ty))
325        .collect::<Vec<_>>()
326        .join(",");
327
328    let registration = emit_handler_registration(fn_name, method, path, &sig.state_type);
329    let schema_registrations = emit_schema_registrations(&func);
330
331    Ok(quote! {
332        #func
333
334        ::rorpc::inventory::submit! {
335            ::rorpc::HandlerMetadata {
336                name: #fn_name_str,
337                method: #method,
338                path: #path,
339                input_type_name: #input_type_str,
340                query_type_name: #query_type_token,
341                output_type_name: #output_type_str,
342                module_path: ::std::module_path!(),
343                namespace: None,
344                error_type_name: #error_type_token,
345                stream_event_type_name: #stream_event_token,
346                path_param_types: #path_param_types_str,
347            }
348        }
349
350        #registration
351        #schema_registrations
352    })
353}
354
355// ---------------------------------------------------------------------------
356// Handler registration factory
357// ---------------------------------------------------------------------------
358
359fn emit_handler_registration(
360    fn_name: &syn::Ident,
361    method: &str,
362    path: &str,
363    state_type: &Option<syn::Type>,
364) -> TokenStream {
365    if let Some(state_ty) = state_type {
366        quote! {
367            ::rorpc::inventory::submit! {
368                ::rorpc::HandlerRegistration {
369                    path: #path,
370                    method: #method,
371                    factory: |state: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>, final_path: &str| {
372                        use ::axum::routing::{delete, get, patch, post, put};
373                        let method_router = match #method {
374                            "GET"    => get(#fn_name),
375                            "POST"   => post(#fn_name),
376                            "PUT"    => put(#fn_name),
377                            "PATCH"  => patch(#fn_name),
378                            "DELETE" => delete(#fn_name),
379                            _        => post(#fn_name),
380                        };
381                        if let Some(typed_state) = state.downcast_ref::<#state_ty>() {
382                            ::axum::Router::new()
383                                .route(final_path, method_router)
384                                .with_state(typed_state.clone())
385                        } else {
386                            ::axum::Router::new()
387                        }
388                    },
389                }
390            }
391        }
392    } else {
393        quote! {
394            ::rorpc::inventory::submit! {
395                ::rorpc::HandlerRegistration {
396                    path: #path,
397                    method: #method,
398                    factory: |_state: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>, final_path: &str| {
399                        use ::axum::routing::{delete, get, patch, post, put};
400                        let method_router = match #method {
401                            "GET"    => get(#fn_name),
402                            "POST"   => post(#fn_name),
403                            "PUT"    => put(#fn_name),
404                            "PATCH"  => patch(#fn_name),
405                            "DELETE" => delete(#fn_name),
406                            _        => post(#fn_name),
407                        };
408                        ::axum::Router::new().route(final_path, method_router)
409                    },
410                }
411            }
412        }
413    }
414}
415
416// ---------------------------------------------------------------------------
417// Schema registrations — z.unknown() fallback for types without #[derive(ZodTs)]
418// ---------------------------------------------------------------------------
419
420fn emit_schema_registrations(func: &ItemFn) -> TokenStream {
421    let mut seen = std::collections::HashSet::new();
422    let mut registrations = Vec::new();
423
424    // Collect candidate types from Json<T> and Query<T> params and return type
425    let mut candidates: Vec<&syn::Type> = Vec::new();
426
427    for arg in &func.sig.inputs {
428        if let syn::FnArg::Typed(pat_type) = arg {
429            // Check for Json<T>
430            if let Some(m) = try_extract_wrapper(&pat_type.ty, JSON)
431                && let Some(inner) = m.first_type()
432            {
433                candidates.push(inner);
434            }
435            // Check for Query<T>
436            if let Some(m) = try_extract_wrapper(&pat_type.ty, QUERY)
437                && let Some(inner) = m.first_type()
438            {
439                candidates.push(inner);
440            }
441        }
442    }
443
444    if let syn::ReturnType::Type(_, ty) = &func.sig.output {
445        // Handle both Json<T> and Result<Json<T>, E>
446        if let Some(m) = try_extract_wrapper(ty, JSON) {
447            if let Some(inner) = m.first_type() {
448                candidates.push(inner);
449            }
450        } else if let Some(result_m) = try_extract_wrapper(ty, RESULT)
451            && let Some(first) = result_m.first_type()
452            && let Some(json_m) = try_extract_wrapper(first, JSON)
453            && let Some(inner) = json_m.first_type()
454        {
455            candidates.push(inner);
456        }
457    }
458
459    for ty in candidates {
460        if let Some(custom_ty) = innermost_custom_type(ty) {
461            if is_primitive(custom_ty) {
462                continue;
463            }
464            let name = type_display(custom_ty);
465            if !seen.insert(name.clone()) {
466                continue;
467            }
468            let fallback = format!(
469                "z.unknown() /* add #[derive(ZodTs)] to {} for a real schema */",
470                name
471            );
472            registrations.push(quote! {
473                ::rorpc::inventory::submit! {
474                    ::rorpc::SchemaRegistration {
475                        type_name: #name,
476                        zod_ts: || #fallback.to_string(),
477                        dependent_types: || vec![],
478                    }
479                }
480            });
481        }
482    }
483
484    quote! { #(#registrations)* }
485}
486
487// ---------------------------------------------------------------------------
488// Tests
489// ---------------------------------------------------------------------------
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use syn::parse_quote;
495
496    #[test]
497    fn parse_data_type_string() {
498        // Test that data = "StreamEvent" (string literal) parses correctly
499        let args: OrpcArgs = syn::parse_quote! {
500            method = "GET", path = "/stream", data = "StreamEvent"
501        };
502
503        assert_eq!(args.method, "GET");
504        assert_eq!(args.path, "/stream");
505        assert_eq!(args.stream_event, Some("StreamEvent".to_string()));
506    }
507
508    #[test]
509    fn parse_data_qualified_path_string() {
510        // Test that data = "crate::models::StreamEvent" works
511        let args: OrpcArgs = syn::parse_quote! {
512            method = "GET", path = "/stream", data = "crate::models::StreamEvent"
513        };
514
515        assert_eq!(
516            args.stream_event,
517            Some("crate::models::StreamEvent".to_string())
518        );
519    }
520
521    #[test]
522    fn parse_data_type_path_backward_compat() {
523        // Test backward compatibility: data = StreamEvent (bare path)
524        let args: OrpcArgs = syn::parse_quote! {
525            method = "GET", path = "/stream", data = StreamEvent
526        };
527
528        assert_eq!(args.method, "GET");
529        assert_eq!(args.path, "/stream");
530        assert_eq!(args.stream_event, Some("StreamEvent".to_string()));
531    }
532
533    #[test]
534    fn parse_without_data() {
535        // Test that data is optional
536        let args: OrpcArgs = syn::parse_quote! {
537            method = "POST", path = "/create"
538        };
539
540        assert_eq!(args.method, "POST");
541        assert_eq!(args.path, "/create");
542        assert_eq!(args.stream_event, None);
543    }
544
545    #[test]
546    fn data_type_converts_to_string_literal() {
547        // Verify that when we generate the metadata, data becomes a string literal
548        let args: OrpcArgs = syn::parse_quote! {
549            method = "GET", path = "/stream", data = "StreamEvent"
550        };
551
552        let func: syn::ItemFn = parse_quote! {
553            async fn stream_test() -> Sse<impl Stream<Item = Event>> {
554                todo!()
555            }
556        };
557
558        let result = try_expand_orpc(args, func);
559        assert!(result.is_ok(), "expand_orpc should succeed");
560
561        // Check that the generated code contains Some("StreamEvent") as a string literal
562        let tokens = result.unwrap().to_string();
563
564        // quote! serialises with spaces between tokens, so `Some("StreamEvent")` becomes
565        // `Some ("StreamEvent")`. Check the field name and the quoted value separately.
566        assert!(
567            tokens.contains("stream_event_type_name") && tokens.contains(r#""StreamEvent""#),
568            "Generated code should contain stream_event_type_name: Some(\"StreamEvent\"), got: {}",
569            tokens
570        );
571        // Also assert it is NOT a bare identifier (which would be a type error at compile time)
572        assert!(
573            !tokens.contains("Some (StreamEvent)") && !tokens.contains("Some(StreamEvent)"),
574            "stream_event_type_name must be a string literal, not a bare identifier"
575        );
576    }
577}
578
579// ---------------------------------------------------------------------------
580// MethodShorthandArgs tests
581// ---------------------------------------------------------------------------
582
583#[test]
584fn parse_shorthand_path_only() {
585    // Test: #[rorpc::get("/planet/list")]
586    let args: MethodShorthandArgs = syn::parse_quote! { "/planet/list" };
587
588    assert_eq!(args.path, "/planet/list");
589    assert_eq!(args.data, None);
590}
591
592#[test]
593fn parse_shorthand_with_data_string() {
594    // Test: #[rorpc::get("/stream", data = "EventData")]
595    let args: MethodShorthandArgs = syn::parse_quote! { "/stream", data = "EventData" };
596
597    assert_eq!(args.path, "/stream");
598    assert_eq!(args.data, Some("EventData".to_string()));
599}
600
601#[test]
602fn parse_shorthand_with_qualified_data() {
603    // Test: #[rorpc::get("/stream", data = "models::EventData")]
604    let args: MethodShorthandArgs = syn::parse_quote! { "/stream", data = "models::EventData" };
605
606    assert_eq!(args.path, "/stream");
607    assert_eq!(args.data, Some("models::EventData".to_string()));
608}
609
610#[test]
611fn parse_shorthand_with_data_type_path() {
612    // Test backward compat: #[rorpc::get("/stream", data = EventData)]
613    let args: MethodShorthandArgs = syn::parse_quote! { "/stream", data = EventData };
614
615    assert_eq!(args.path, "/stream");
616    assert_eq!(args.data, Some("EventData".to_string()));
617}
618
619#[test]
620fn shorthand_converts_to_orpc_args() {
621    let shorthand: MethodShorthandArgs = syn::parse_quote! { "/planet/list" };
622    let args = shorthand.into_orpc_args("GET");
623
624    assert_eq!(args.method, "GET");
625    assert_eq!(args.path, "/planet/list");
626    assert_eq!(args.stream_event, None);
627}
628
629#[test]
630fn shorthand_with_data_converts_to_orpc_args() {
631    let shorthand: MethodShorthandArgs = syn::parse_quote! { "/stream", data = "EventData" };
632    let args = shorthand.into_orpc_args("GET");
633
634    assert_eq!(args.method, "GET");
635    assert_eq!(args.path, "/stream");
636    assert_eq!(args.stream_event, Some("EventData".to_string()));
637}
638
639#[test]
640fn shorthand_method_normalized_to_uppercase() {
641    let shorthand: MethodShorthandArgs = syn::parse_quote! { "/test" };
642    let args = shorthand.into_orpc_args("get"); // lowercase input
643
644    assert_eq!(args.method, "GET"); // should be uppercase
645}