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                error_type_name: #error_type_token,
344                stream_event_type_name: #stream_event_token,
345                path_param_types: #path_param_types_str,
346            }
347        }
348
349        #registration
350        #schema_registrations
351    })
352}
353
354// ---------------------------------------------------------------------------
355// Handler registration factory
356// ---------------------------------------------------------------------------
357
358fn emit_handler_registration(
359    fn_name: &syn::Ident,
360    method: &str,
361    path: &str,
362    state_type: &Option<syn::Type>,
363) -> TokenStream {
364    if let Some(state_ty) = state_type {
365        quote! {
366            ::rorpc::inventory::submit! {
367                ::rorpc::HandlerRegistration {
368                    path: #path,
369                    method: #method,
370                    factory: |state: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>| {
371                        use ::axum::routing::{delete, get, patch, post, put};
372                        let method_router = match #method {
373                            "GET"    => get(#fn_name),
374                            "POST"   => post(#fn_name),
375                            "PUT"    => put(#fn_name),
376                            "PATCH"  => patch(#fn_name),
377                            "DELETE" => delete(#fn_name),
378                            _        => post(#fn_name),
379                        };
380                        if let Some(typed_state) = state.downcast_ref::<#state_ty>() {
381                            ::axum::Router::new()
382                                .route(#path, method_router)
383                                .with_state(typed_state.clone())
384                        } else {
385                            ::axum::Router::new()
386                        }
387                    },
388                }
389            }
390        }
391    } else {
392        quote! {
393            ::rorpc::inventory::submit! {
394                ::rorpc::HandlerRegistration {
395                    path: #path,
396                    method: #method,
397                    factory: |_state: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>| {
398                        use ::axum::routing::{delete, get, patch, post, put};
399                        let method_router = match #method {
400                            "GET"    => get(#fn_name),
401                            "POST"   => post(#fn_name),
402                            "PUT"    => put(#fn_name),
403                            "PATCH"  => patch(#fn_name),
404                            "DELETE" => delete(#fn_name),
405                            _        => post(#fn_name),
406                        };
407                        ::axum::Router::new().route(#path, method_router)
408                    },
409                }
410            }
411        }
412    }
413}
414
415// ---------------------------------------------------------------------------
416// Schema registrations — z.unknown() fallback for types without #[derive(ZodTs)]
417// ---------------------------------------------------------------------------
418
419fn emit_schema_registrations(func: &ItemFn) -> TokenStream {
420    let mut seen = std::collections::HashSet::new();
421    let mut registrations = Vec::new();
422
423    // Collect candidate types from Json<T> and Query<T> params and return type
424    let mut candidates: Vec<&syn::Type> = Vec::new();
425
426    for arg in &func.sig.inputs {
427        if let syn::FnArg::Typed(pat_type) = arg {
428            // Check for Json<T>
429            if let Some(m) = try_extract_wrapper(&pat_type.ty, JSON)
430                && let Some(inner) = m.first_type()
431            {
432                candidates.push(inner);
433            }
434            // Check for Query<T>
435            if let Some(m) = try_extract_wrapper(&pat_type.ty, QUERY)
436                && let Some(inner) = m.first_type()
437            {
438                candidates.push(inner);
439            }
440        }
441    }
442
443    if let syn::ReturnType::Type(_, ty) = &func.sig.output {
444        // Handle both Json<T> and Result<Json<T>, E>
445        if let Some(m) = try_extract_wrapper(ty, JSON) {
446            if let Some(inner) = m.first_type() {
447                candidates.push(inner);
448            }
449        } else if let Some(result_m) = try_extract_wrapper(ty, RESULT)
450            && let Some(first) = result_m.first_type()
451            && let Some(json_m) = try_extract_wrapper(first, JSON)
452            && let Some(inner) = json_m.first_type()
453        {
454            candidates.push(inner);
455        }
456    }
457
458    for ty in candidates {
459        if let Some(custom_ty) = innermost_custom_type(ty) {
460            if is_primitive(custom_ty) {
461                continue;
462            }
463            let name = type_display(custom_ty);
464            if !seen.insert(name.clone()) {
465                continue;
466            }
467            let fallback = format!(
468                "z.unknown() /* add #[derive(ZodTs)] to {} for a real schema */",
469                name
470            );
471            registrations.push(quote! {
472                ::rorpc::inventory::submit! {
473                    ::rorpc::SchemaRegistration {
474                        type_name: #name,
475                        zod_ts: || #fallback.to_string(),
476                        dependent_types: || vec![],
477                    }
478                }
479            });
480        }
481    }
482
483    quote! { #(#registrations)* }
484}
485
486// ---------------------------------------------------------------------------
487// Tests
488// ---------------------------------------------------------------------------
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use syn::parse_quote;
494
495    #[test]
496    fn parse_data_type_string() {
497        // Test that data = "StreamEvent" (string literal) parses correctly
498        let args: OrpcArgs = syn::parse_quote! {
499            method = "GET", path = "/stream", data = "StreamEvent"
500        };
501
502        assert_eq!(args.method, "GET");
503        assert_eq!(args.path, "/stream");
504        assert_eq!(args.stream_event, Some("StreamEvent".to_string()));
505    }
506
507    #[test]
508    fn parse_data_qualified_path_string() {
509        // Test that data = "crate::models::StreamEvent" works
510        let args: OrpcArgs = syn::parse_quote! {
511            method = "GET", path = "/stream", data = "crate::models::StreamEvent"
512        };
513
514        assert_eq!(
515            args.stream_event,
516            Some("crate::models::StreamEvent".to_string())
517        );
518    }
519
520    #[test]
521    fn parse_data_type_path_backward_compat() {
522        // Test backward compatibility: data = StreamEvent (bare path)
523        let args: OrpcArgs = syn::parse_quote! {
524            method = "GET", path = "/stream", data = StreamEvent
525        };
526
527        assert_eq!(args.method, "GET");
528        assert_eq!(args.path, "/stream");
529        assert_eq!(args.stream_event, Some("StreamEvent".to_string()));
530    }
531
532    #[test]
533    fn parse_without_data() {
534        // Test that data is optional
535        let args: OrpcArgs = syn::parse_quote! {
536            method = "POST", path = "/create"
537        };
538
539        assert_eq!(args.method, "POST");
540        assert_eq!(args.path, "/create");
541        assert_eq!(args.stream_event, None);
542    }
543
544    #[test]
545    fn data_type_converts_to_string_literal() {
546        // Verify that when we generate the metadata, data becomes a string literal
547        let args: OrpcArgs = syn::parse_quote! {
548            method = "GET", path = "/stream", data = "StreamEvent"
549        };
550
551        let func: syn::ItemFn = parse_quote! {
552            async fn stream_test() -> Sse<impl Stream<Item = Event>> {
553                todo!()
554            }
555        };
556
557        let result = try_expand_orpc(args, func);
558        assert!(result.is_ok(), "expand_orpc should succeed");
559
560        // Check that the generated code contains Some("StreamEvent") as a string literal
561        let tokens = result.unwrap().to_string();
562
563        // quote! serialises with spaces between tokens, so `Some("StreamEvent")` becomes
564        // `Some ("StreamEvent")`. Check the field name and the quoted value separately.
565        assert!(
566            tokens.contains("stream_event_type_name") && tokens.contains(r#""StreamEvent""#),
567            "Generated code should contain stream_event_type_name: Some(\"StreamEvent\"), got: {}",
568            tokens
569        );
570        // Also assert it is NOT a bare identifier (which would be a type error at compile time)
571        assert!(
572            !tokens.contains("Some (StreamEvent)") && !tokens.contains("Some(StreamEvent)"),
573            "stream_event_type_name must be a string literal, not a bare identifier"
574        );
575    }
576}
577
578// ---------------------------------------------------------------------------
579// MethodShorthandArgs tests
580// ---------------------------------------------------------------------------
581
582#[test]
583fn parse_shorthand_path_only() {
584    // Test: #[rorpc::get("/planet/list")]
585    let args: MethodShorthandArgs = syn::parse_quote! { "/planet/list" };
586
587    assert_eq!(args.path, "/planet/list");
588    assert_eq!(args.data, None);
589}
590
591#[test]
592fn parse_shorthand_with_data_string() {
593    // Test: #[rorpc::get("/stream", data = "EventData")]
594    let args: MethodShorthandArgs = syn::parse_quote! { "/stream", data = "EventData" };
595
596    assert_eq!(args.path, "/stream");
597    assert_eq!(args.data, Some("EventData".to_string()));
598}
599
600#[test]
601fn parse_shorthand_with_qualified_data() {
602    // Test: #[rorpc::get("/stream", data = "models::EventData")]
603    let args: MethodShorthandArgs = syn::parse_quote! { "/stream", data = "models::EventData" };
604
605    assert_eq!(args.path, "/stream");
606    assert_eq!(args.data, Some("models::EventData".to_string()));
607}
608
609#[test]
610fn parse_shorthand_with_data_type_path() {
611    // Test backward compat: #[rorpc::get("/stream", data = EventData)]
612    let args: MethodShorthandArgs = syn::parse_quote! { "/stream", data = EventData };
613
614    assert_eq!(args.path, "/stream");
615    assert_eq!(args.data, Some("EventData".to_string()));
616}
617
618#[test]
619fn shorthand_converts_to_orpc_args() {
620    let shorthand: MethodShorthandArgs = syn::parse_quote! { "/planet/list" };
621    let args = shorthand.into_orpc_args("GET");
622
623    assert_eq!(args.method, "GET");
624    assert_eq!(args.path, "/planet/list");
625    assert_eq!(args.stream_event, None);
626}
627
628#[test]
629fn shorthand_with_data_converts_to_orpc_args() {
630    let shorthand: MethodShorthandArgs = syn::parse_quote! { "/stream", data = "EventData" };
631    let args = shorthand.into_orpc_args("GET");
632
633    assert_eq!(args.method, "GET");
634    assert_eq!(args.path, "/stream");
635    assert_eq!(args.stream_event, Some("EventData".to_string()));
636}
637
638#[test]
639fn shorthand_method_normalized_to_uppercase() {
640    let shorthand: MethodShorthandArgs = syn::parse_quote! { "/test" };
641    let args = shorthand.into_orpc_args("get"); // lowercase input
642
643    assert_eq!(args.method, "GET"); // should be uppercase
644}