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// OrpcArgs — parsed from #[orpc(method = "...", path = "...", stream_event = TypePath)]
25// ---------------------------------------------------------------------------
26
27/// Parsed arguments for the `#[orpc(...)]` attribute.
28pub struct OrpcArgs {
29    pub method: String,
30    pub path: String,
31    pub stream_event: Option<syn::Type>,
32}
33
34const VALID_KEYS: &[&str] = &["method", "path", "stream_event"];
35
36impl Parse for OrpcArgs {
37    fn parse(input: ParseStream) -> syn::Result<Self> {
38        let pairs = Punctuated::<MetaNameValue, Token![,]>::parse_terminated(input)?;
39
40        let mut method = None;
41        let mut path = None;
42        let mut stream_event = None;
43
44        for pair in &pairs {
45            let key = pair
46                .path
47                .get_ident()
48                .map(|i| i.to_string())
49                .unwrap_or_default();
50
51            let span = pair
52                .path
53                .get_ident()
54                .map(|i| i.span())
55                .unwrap_or_else(proc_macro2::Span::call_site);
56
57            match key.as_str() {
58                "method" => {
59                    if let Expr::Lit(ExprLit {
60                        lit: Lit::Str(s), ..
61                    }) = &pair.value
62                    {
63                        method = Some(s.value().to_uppercase());
64                    } else {
65                        return Err(syn::Error::new(
66                            span,
67                            Error::invalid_attr_value(
68                                span,
69                                &key,
70                                "a string literal",
71                                "non-string expression",
72                            )
73                            .to_string(),
74                        ));
75                    }
76                }
77                "path" => {
78                    if let Expr::Lit(ExprLit {
79                        lit: Lit::Str(s), ..
80                    }) = &pair.value
81                    {
82                        path = Some(s.value());
83                    } else {
84                        return Err(syn::Error::new(
85                            span,
86                            Error::invalid_attr_value(
87                                span,
88                                &key,
89                                "a string literal",
90                                "non-string expression",
91                            )
92                            .to_string(),
93                        ));
94                    }
95                }
96                "stream_event" => {
97                    // Accept a type path: stream_event = StreamEvent
98                    if let Expr::Path(expr_path) = &pair.value {
99                        let type_path = syn::TypePath {
100                            attrs: vec![],
101                            qself: expr_path.qself.clone(),
102                            path: expr_path.path.clone(),
103                        };
104                        stream_event = Some(syn::Type::Path(type_path));
105                    } else {
106                        return Err(syn::Error::new(
107                            span,
108                            "stream_event must be a type path (e.g., StreamEvent or module::StreamEvent)",
109                        ));
110                    }
111                }
112                _ => {
113                    return Err(syn::Error::new(
114                        span,
115                        Error::unknown_key(span, &key, VALID_KEYS).to_string(),
116                    ));
117                }
118            }
119        }
120
121        let method = method.ok_or_else(|| {
122            syn::Error::new(
123                proc_macro2::Span::call_site(),
124                Error::missing_required_attr(
125                    proc_macro2::Span::call_site(),
126                    "method",
127                    "add `method = \"GET\"` to #[rorpc]",
128                )
129                .to_string(),
130            )
131        })?;
132
133        let path = path.ok_or_else(|| {
134            syn::Error::new(
135                proc_macro2::Span::call_site(),
136                Error::missing_required_attr(
137                    proc_macro2::Span::call_site(),
138                    "path",
139                    "add `path = \"/your/route\"` to #[rorpc]",
140                )
141                .to_string(),
142            )
143        })?;
144
145        Ok(OrpcArgs {
146            method,
147            path,
148            stream_event,
149        })
150    }
151}
152
153// ---------------------------------------------------------------------------
154// expand_orpc
155// ---------------------------------------------------------------------------
156
157/// Generate the full expansion for `#[orpc(method, path)] async fn handler(...)`.
158///
159/// Returns the original function unchanged plus all inventory registrations.
160pub fn expand_orpc(args: OrpcArgs, func: ItemFn) -> TokenStream {
161    match try_expand_orpc(args, func) {
162        Ok(ts) => ts,
163        Err(e) => e.to_compile_error(),
164    }
165}
166
167fn try_expand_orpc(args: OrpcArgs, func: ItemFn) -> Result<TokenStream> {
168    let sig = extract_handler_signature(&func)?;
169
170    let fn_name = &func.sig.ident;
171    let fn_name_str = sig.fn_name.as_str();
172    let method = &args.method;
173    let path = &args.path;
174
175    let output_type_str = type_display(&sig.output_type);
176
177    let error_type_token = match &sig.error_type {
178        Some(ty) => {
179            let s = type_display(ty);
180            quote! { Some(#s) }
181        }
182        None => quote! { None },
183    };
184
185    let stream_event_token = match &args.stream_event {
186        Some(ty) => {
187            let s = type_display(ty);
188            quote! { Some(#s) }
189        }
190        None => quote! { None },
191    };
192
193    let input_type_str = match &sig.input_type {
194        Some(ty) => type_display(ty),
195        None => "()".to_string(),
196    };
197
198    let query_type_token = match &sig.query_type {
199        Some(ty) => {
200            let s = type_display(ty);
201            quote! { Some(#s) }
202        }
203        None => quote! { None },
204    };
205
206    let registration = emit_handler_registration(fn_name, method, path, &sig.state_type);
207    let schema_registrations = emit_schema_registrations(&func);
208
209    Ok(quote! {
210        #func
211
212        ::rorpc::inventory::submit! {
213            ::rorpc::HandlerMetadata {
214                name: #fn_name_str,
215                method: #method,
216                path: #path,
217                input_type_name: #input_type_str,
218                query_type_name: #query_type_token,
219                output_type_name: #output_type_str,
220                module_path: ::std::module_path!(),
221                error_type_name: #error_type_token,
222                stream_event_type_name: #stream_event_token,
223            }
224        }
225
226        #registration
227        #schema_registrations
228    })
229}
230
231// ---------------------------------------------------------------------------
232// Handler registration factory
233// ---------------------------------------------------------------------------
234
235fn emit_handler_registration(
236    fn_name: &syn::Ident,
237    method: &str,
238    path: &str,
239    state_type: &Option<syn::Type>,
240) -> TokenStream {
241    if let Some(state_ty) = state_type {
242        quote! {
243            ::rorpc::inventory::submit! {
244                ::rorpc::HandlerRegistration {
245                    path: #path,
246                    method: #method,
247                    factory: |state: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>| {
248                        use ::axum::routing::{delete, get, patch, post, put};
249                        let method_router = match #method {
250                            "GET"    => get(#fn_name),
251                            "POST"   => post(#fn_name),
252                            "PUT"    => put(#fn_name),
253                            "PATCH"  => patch(#fn_name),
254                            "DELETE" => delete(#fn_name),
255                            _        => post(#fn_name),
256                        };
257                        if let Some(typed_state) = state.downcast_ref::<#state_ty>() {
258                            ::axum::Router::new()
259                                .route(#path, method_router)
260                                .with_state(typed_state.clone())
261                        } else {
262                            ::axum::Router::new()
263                        }
264                    },
265                }
266            }
267        }
268    } else {
269        quote! {
270            ::rorpc::inventory::submit! {
271                ::rorpc::HandlerRegistration {
272                    path: #path,
273                    method: #method,
274                    factory: |_state: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>| {
275                        use ::axum::routing::{delete, get, patch, post, put};
276                        let method_router = match #method {
277                            "GET"    => get(#fn_name),
278                            "POST"   => post(#fn_name),
279                            "PUT"    => put(#fn_name),
280                            "PATCH"  => patch(#fn_name),
281                            "DELETE" => delete(#fn_name),
282                            _        => post(#fn_name),
283                        };
284                        ::axum::Router::new().route(#path, method_router)
285                    },
286                }
287            }
288        }
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Schema registrations — z.unknown() fallback for types without #[derive(ZodTs)]
294// ---------------------------------------------------------------------------
295
296fn emit_schema_registrations(func: &ItemFn) -> TokenStream {
297    let mut seen = std::collections::HashSet::new();
298    let mut registrations = Vec::new();
299
300    // Collect candidate types from Json<T> and Query<T> params and return type
301    let mut candidates: Vec<&syn::Type> = Vec::new();
302
303    for arg in &func.sig.inputs {
304        if let syn::FnArg::Typed(pat_type) = arg {
305            // Check for Json<T>
306            if let Some(m) = try_extract_wrapper(&pat_type.ty, JSON)
307                && let Some(inner) = m.first_type()
308            {
309                candidates.push(inner);
310            }
311            // Check for Query<T>
312            if let Some(m) = try_extract_wrapper(&pat_type.ty, QUERY)
313                && let Some(inner) = m.first_type()
314            {
315                candidates.push(inner);
316            }
317        }
318    }
319
320    if let syn::ReturnType::Type(_, ty) = &func.sig.output {
321        // Handle both Json<T> and Result<Json<T>, E>
322        if let Some(m) = try_extract_wrapper(ty, JSON) {
323            if let Some(inner) = m.first_type() {
324                candidates.push(inner);
325            }
326        } else if let Some(result_m) = try_extract_wrapper(ty, RESULT)
327            && let Some(first) = result_m.first_type()
328            && let Some(json_m) = try_extract_wrapper(first, JSON)
329            && let Some(inner) = json_m.first_type()
330        {
331            candidates.push(inner);
332        }
333    }
334
335    for ty in candidates {
336        if let Some(custom_ty) = innermost_custom_type(ty) {
337            if is_primitive(custom_ty) {
338                continue;
339            }
340            let name = type_display(custom_ty);
341            if !seen.insert(name.clone()) {
342                continue;
343            }
344            let fallback = format!(
345                "z.unknown() /* add #[derive(ZodTs)] to {} for a real schema */",
346                name
347            );
348            registrations.push(quote! {
349                ::rorpc::inventory::submit! {
350                    ::rorpc::SchemaRegistration {
351                        type_name: #name,
352                        zod_ts: || #fallback.to_string(),
353                        dependent_types: || vec![],
354                    }
355                }
356            });
357        }
358    }
359
360    quote! { #(#registrations)* }
361}
362
363// ---------------------------------------------------------------------------
364// Tests
365// ---------------------------------------------------------------------------
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use syn::parse_quote;
371
372    #[test]
373    fn parse_stream_event_type_path() {
374        // Test that stream_event = StreamEvent (without quotes) parses correctly
375        let args: OrpcArgs = syn::parse_quote! {
376            method = "GET", path = "/stream", stream_event = StreamEvent
377        };
378
379        assert_eq!(args.method, "GET");
380        assert_eq!(args.path, "/stream");
381        assert!(args.stream_event.is_some());
382
383        // Verify type_display produces the correct string
384        let ty = args.stream_event.unwrap();
385        let type_str = crate::errors::type_display(&ty);
386        assert_eq!(type_str, "StreamEvent");
387    }
388
389    #[test]
390    fn parse_stream_event_qualified_path() {
391        // Test that stream_event = crate::models::StreamEvent works
392        let args: OrpcArgs = syn::parse_quote! {
393            method = "GET", path = "/stream", stream_event = crate::models::StreamEvent
394        };
395
396        assert!(args.stream_event.is_some());
397        let ty = args.stream_event.unwrap();
398        let type_str = crate::errors::type_display(&ty);
399        assert_eq!(type_str, "crate::models::StreamEvent");
400    }
401
402    #[test]
403    fn parse_without_stream_event() {
404        // Test that stream_event is optional
405        let args: OrpcArgs = syn::parse_quote! {
406            method = "POST", path = "/create"
407        };
408
409        assert_eq!(args.method, "POST");
410        assert_eq!(args.path, "/create");
411        assert!(args.stream_event.is_none());
412    }
413
414    #[test]
415    fn stream_event_type_converts_to_string_literal() {
416        // This is the critical test for the bug fix
417        // Verify that when we generate the metadata, stream_event becomes a string literal
418        let args: OrpcArgs = syn::parse_quote! {
419            method = "GET", path = "/stream", stream_event = StreamEvent
420        };
421
422        let func: syn::ItemFn = parse_quote! {
423            async fn stream_test() -> Sse<impl Stream<Item = Event>> {
424                todo!()
425            }
426        };
427
428        let result = try_expand_orpc(args, func);
429        assert!(result.is_ok(), "expand_orpc should succeed");
430
431        // Check that the generated code contains Some("StreamEvent") as a string literal
432        let tokens = result.unwrap().to_string();
433
434        // quote! serialises with spaces between tokens, so `Some("StreamEvent")` becomes
435        // `Some ("StreamEvent")`. Check the field name and the quoted value separately.
436        assert!(
437            tokens.contains("stream_event_type_name") && tokens.contains(r#""StreamEvent""#),
438            "Generated code should contain stream_event_type_name: Some(\"StreamEvent\"), got: {}",
439            tokens
440        );
441        // Also assert it is NOT a bare identifier (which would be a type error at compile time)
442        assert!(
443            !tokens.contains("Some (StreamEvent)") && !tokens.contains("Some(StreamEvent)"),
444            "stream_event_type_name must be a string literal, not a bare identifier"
445        );
446    }
447}