qsync/
processor.rs

1use super::hook::{Hook, HookBodyParam, HookPathParam, HookQueryParam};
2use super::params::generic_to_typsecript_type;
3use darling::FromMeta;
4use regex::Regex;
5use std::fs::File;
6use std::io::Read;
7use std::io::Write;
8use std::path::Path;
9use std::path::PathBuf;
10
11use crate::utils::file_path_to_vec_string;
12use syn::FnArg;
13use syn::Pat;
14use syn::PatType;
15use syn::PathArguments;
16use syn::Type;
17use syn::TypePath;
18use walkdir::WalkDir;
19
20extern crate inflector;
21use super::processor::inflector::Inflector;
22
23struct QsyncAttributeProps {
24    return_type: String,
25    is_mutation: bool,
26}
27
28#[derive(Debug, FromMeta)]
29pub struct MacroArgs {
30    return_type: Option<String>,
31    mutate: Option<bool>,
32}
33
34fn has_qsync_attribute(
35    is_debug: bool,
36    attributes: &[syn::Attribute],
37) -> Option<QsyncAttributeProps> {
38    let mut is_mutation: Option<bool> = None;
39    let mut return_type = "TODO".to_string();
40
41    let mut has_actix_attribute = false;
42    let mut has_qsync_attribute = false;
43
44    for attr in attributes.iter() {
45        if is_debug {
46            let attr_path = attr
47                .path
48                .segments
49                .iter()
50                .map(|r| r.ident.to_string())
51                .collect::<Vec<String>>()
52                .join("::");
53            println!("\tFound attribute '{}'", &attr_path);
54        }
55
56        let meta = attr.parse_meta();
57
58        if meta.is_err() {
59            if is_debug {
60                println!("\t> could not parse attribute as `Meta`");
61            }
62            continue;
63        }
64
65        let meta = meta.unwrap();
66
67        // extract and compare against attribute's identifier (#[path::to::identifier])
68        let attr_identifier = meta
69            .path()
70            .segments
71            .last()
72            .map(|r| r.ident.to_string())
73            .unwrap_or_default();
74
75        match attr_identifier.as_str() {
76            "qsync" => {
77                has_qsync_attribute = true;
78
79                let args = MacroArgs::from_meta(&meta);
80                if args.is_err() {
81                    if is_debug {
82                        println!("qsync error reading attribute args: {:?}", &args.err());
83                    }
84                    continue;
85                }
86                let args = args.unwrap();
87
88                if args.mutate.is_some() {
89                    is_mutation = args.mutate;
90                }
91                if args.return_type.is_some() {
92                    return_type = args.return_type.unwrap();
93                }
94            }
95            "get" => {
96                has_actix_attribute = true;
97                if is_mutation.is_none() {
98                    is_mutation = Some(false);
99                }
100            }
101            "post" => {
102                has_actix_attribute = true;
103                if is_mutation.is_none() {
104                    is_mutation = Some(true);
105                }
106            }
107            "patch" => {
108                has_actix_attribute = true;
109                if is_mutation.is_none() {
110                    is_mutation = Some(true);
111                }
112            }
113            "put" => {
114                has_actix_attribute = true;
115                if is_mutation.is_none() {
116                    is_mutation = Some(true);
117                }
118            }
119            "delete" => {
120                has_actix_attribute = true;
121                if is_mutation.is_none() {
122                    is_mutation = Some(true);
123                }
124            }
125            _ => {}
126        }
127    }
128
129    if has_actix_attribute && has_qsync_attribute {
130        Some(QsyncAttributeProps {
131            is_mutation: is_mutation.unwrap_or_default(),
132            return_type,
133        })
134    } else {
135        None
136    }
137}
138
139#[cfg(test)]
140mod processor_tests {
141    #[test]
142    fn test_hook_printing() {
143        println!("test complete!");
144    }
145}
146
147#[derive(Debug)]
148pub enum HttpVerb {
149    Get,
150    Post,
151    Put,
152    Delete,
153    Unknown,
154}
155enum ParamType {
156    Auth,
157    Query,
158    Body,
159    Path,
160    Unknown,
161}
162
163struct InputType {
164    arg_name: String,
165    arg_type: String,
166    param_type: ParamType,
167}
168
169fn get_api_fn_input_param_type(
170    query_input: QsyncInput,
171    pat_type: PatType,
172    type_path: TypePath,
173) -> InputType {
174    let mut arg_name: String = "unknown".to_string();
175    let mut arg_type: String = "any".to_string();
176
177    if let Pat::TupleStruct(tuple_struct) = *pat_type.pat {
178        let ident_elem = tuple_struct.pat.elems.last();
179        if let Some(Pat::Ident(ident)) = ident_elem {
180            let ident = ident.ident.clone();
181            arg_name = ident.to_string();
182        }
183    }
184
185    let segments: syn::punctuated::Punctuated<syn::PathSegment, syn::token::Colon2> =
186        type_path.path.segments;
187
188    let last_segment = segments.last();
189
190    if let Some(last_segment) = last_segment {
191        // ```
192        // #[qsync]
193        // async fn endpoint(path: Path<SomeType>) -> HttpResponse { ... }
194        //                         ----
195        //                         ^ this is the `endpoint_param_type`
196        // ```
197        let endpoint_param_type = last_segment.clone().ident.to_string();
198        let param_type: ParamType = match endpoint_param_type.as_str() {
199            "Path" => ParamType::Path,
200            "Json" => ParamType::Body,
201            "Form" => ParamType::Body,
202            "Query" => ParamType::Query,
203            "Auth" => ParamType::Auth,
204            _ => {
205                if query_input
206                    .options
207                    .auth_extractors
208                    .contains(&endpoint_param_type.to_string())
209                {
210                    ParamType::Auth
211                } else {
212                    ParamType::Unknown
213                }
214            }
215        };
216
217        if let PathArguments::AngleBracketed(angled) = last_segment.clone().arguments {
218            for arg in angled.args {
219                arg_type = generic_to_typsecript_type(&arg);
220            }
221        }
222
223        InputType {
224            param_type,
225            arg_name,
226            arg_type,
227        }
228    } else {
229        InputType {
230            param_type: ParamType::Unknown,
231            arg_name,
232            arg_type,
233        }
234    }
235}
236
237fn extract_path_params_from_hook_endpoint_url(hook: &mut Hook) {
238    if hook.endpoint_url.is_empty() {
239        panic!("cannot extract path params because endpoint_url is empty!");
240    }
241
242    let re = Regex::new(r"\{[A-Za-z0-9_]+}").unwrap();
243    for path_param_text in re.find_iter(hook.endpoint_url.as_str()) {
244        let path_param_text = path_param_text
245            .as_str()
246            .trim_start_matches('{')
247            .trim_end_matches('}')
248            .to_string();
249        hook.path_params.push(HookPathParam {
250            hook_arg_name: path_param_text,
251            hook_arg_type: "string".to_string(),
252        })
253    }
254}
255
256fn extract_endpoint_information(
257    endpoint_prefix: String,
258    base_input_path: &Path,
259    input_path: &Path,
260    attributes: &Vec<syn::Attribute>,
261    hook: &mut Hook,
262) {
263    let mut verb = HttpVerb::Unknown;
264    let mut path = "".to_string();
265
266    for attr in attributes {
267        let last_segment = attr.path.segments.last();
268        if let Some(potential_verb) = last_segment {
269            let potential_verb = potential_verb.ident.to_string();
270
271            if potential_verb.eq_ignore_ascii_case("get") {
272                verb = HttpVerb::Get;
273            } else if potential_verb.eq_ignore_ascii_case("post") {
274                verb = HttpVerb::Post;
275            } else if potential_verb.eq_ignore_ascii_case("put") {
276                verb = HttpVerb::Put;
277            } else if potential_verb.eq_ignore_ascii_case("delete") {
278                verb = HttpVerb::Delete;
279            }
280        }
281
282        if !matches!(verb, HttpVerb::Unknown) {
283            for token in attr.clone().tokens {
284                if let proc_macro2::TokenTree::Group(g) = token {
285                    for x in g.stream() {
286                        if let proc_macro2::TokenTree::Literal(lit) = x {
287                            path = lit.to_string().trim_matches('"').to_string();
288                        }
289                    }
290                }
291            }
292        }
293    }
294
295    let endpoint_base = input_path
296        .parent()
297        .unwrap()
298        .strip_prefix(base_input_path)
299        .unwrap()
300        .to_str()
301        .unwrap()
302        .trim_start_matches("/")
303        .trim_end_matches("/");
304
305    // the part extracted from the attribute, for example: `#[post("/{id}"]`
306    let handler_path = path.trim_start_matches('/').trim_end_matches('/');
307
308    hook.endpoint_url = format!("{endpoint_prefix}/{endpoint_base}/{handler_path}")
309        .trim_end_matches("/")
310        .to_string();
311
312    hook.endpoint_verb = verb;
313}
314
315struct BuildState /*<'a>*/ {
316    pub types: String,
317    pub hooks: Vec<Hook>,
318    pub unprocessed_files: Vec<PathBuf>,
319    // pub ignore_file_config: Option<gitignore::File<'a>>,
320    pub is_debug: bool, // this is a hack, we shouldn't have is_debug in the build state since it's global state rather than build/input-path specific state.
321}
322
323fn generate_hook_name(base_input_path: &Path, input_path: &Path, fn_name: String) -> String {
324    let relative_file_path = input_path.strip_prefix(base_input_path).unwrap();
325
326    let mut hook_name: Vec<String> = file_path_to_vec_string(relative_file_path);
327
328    hook_name.insert(0, "use".to_string());
329    hook_name.push(fn_name.to_pascal_case());
330    hook_name.join("")
331}
332
333fn process_service_file(
334    endpoint_prefix: String,
335    base_input_path: PathBuf,
336    input_path: PathBuf,
337    state: &mut BuildState,
338    input: QsyncInput,
339) {
340    if state.is_debug {
341        println!(
342            "processing rust file: {:?}",
343            input_path.clone().into_os_string().into_string().unwrap()
344        );
345    }
346
347    let file = File::open(&input_path);
348
349    if file.is_err() {
350        state.unprocessed_files.push(input_path);
351        return;
352    }
353
354    let mut file = file.unwrap();
355
356    let mut src = String::new();
357    if file.read_to_string(&mut src).is_err() {
358        state.unprocessed_files.push(input_path);
359        return;
360    }
361
362    let syntax = syn::parse_file(&src);
363
364    if syntax.is_err() {
365        state.unprocessed_files.push(input_path);
366        return;
367    }
368
369    let syntax = syntax.unwrap();
370
371    for item in syntax.items {
372        if let syn::Item::Fn(exported_fn) = item {
373            let qsync_props = has_qsync_attribute(state.is_debug, &exported_fn.attrs);
374
375            let has_qsync_attribute = qsync_props.is_some();
376
377            if state.is_debug {
378                if has_qsync_attribute {
379                    println!(
380                        "Encountered #[get|post|put|delete] struct: {}",
381                        exported_fn.sig.ident
382                    );
383                } else {
384                    println!("Encountered non-query struct: {}", exported_fn.sig.ident);
385                }
386            }
387
388            if has_qsync_attribute {
389                let qsync_props = qsync_props.unwrap();
390                let mut hook = Hook {
391                    uses_auth: false,
392                    endpoint_url: "".to_string(),
393                    endpoint_verb: HttpVerb::Unknown,
394                    is_mutation: qsync_props.is_mutation,
395                    return_type: qsync_props.return_type,
396                    hook_name: generate_hook_name(
397                        &base_input_path,
398                        &input_path,
399                        exported_fn.sig.ident.to_string(),
400                    ),
401                    body_params: vec![],
402                    path_params: vec![],
403                    query_params: vec![],
404                    generated_from: input_path.clone(),
405                    generation_options: input.clone(),
406                };
407
408                extract_endpoint_information(
409                    endpoint_prefix.clone(),
410                    &base_input_path,
411                    &input_path,
412                    &exported_fn.attrs,
413                    &mut hook,
414                );
415                extract_path_params_from_hook_endpoint_url(&mut hook);
416
417                for arg in exported_fn.sig.inputs {
418                    if let FnArg::Typed(typed_arg) = arg.clone() {
419                        if let Type::Path(type_path) = *typed_arg.clone().ty {
420                            let input_type = get_api_fn_input_param_type(
421                                input.clone(),
422                                typed_arg.clone(),
423                                type_path,
424                            );
425
426                            match input_type.param_type {
427                                ParamType::Auth => {
428                                    // TODO: what about custom auth types like API tokens?
429                                    if state.is_debug {
430                                        println!(
431                                            "\t> ParamType::AUTH (create_rust_app::auth::Auth)",
432                                        );
433                                    }
434                                    hook.uses_auth = true;
435                                }
436                                ParamType::Body => {
437                                    if state.is_debug {
438                                        println!(
439                                            "\t> ParamType::BODY '{}: {}'",
440                                            input_type.arg_name, input_type.arg_type
441                                        );
442                                    }
443                                    hook.body_params.push(HookBodyParam {
444                                        hook_arg_name: input_type.arg_name,
445                                        hook_arg_type: input_type.arg_type,
446                                    });
447                                }
448                                ParamType::Path => {
449                                    if state.is_debug {
450                                        println!("\t> ParamType::PATH '{}: {}', (ignored; extracted from endpoint url)", input_type.arg_name, input_type.arg_type);
451                                    }
452                                    // hook.path_params.push(HookPathParam {
453                                    //     hook_arg_name: input_type.arg_name,
454                                    //     hook_arg_type: input_type.arg_type,
455                                    // });
456                                }
457                                ParamType::Query => {
458                                    if state.is_debug {
459                                        println!(
460                                            "\t> ParamType::QUERY '{}: {}'",
461                                            input_type.arg_name, input_type.arg_type
462                                        );
463                                    }
464
465                                    hook.query_params.push(HookQueryParam {
466                                        hook_arg_name: input_type.arg_name,
467                                        hook_arg_type: input_type.arg_type,
468                                    });
469                                }
470                                ParamType::Unknown => {
471                                    if state.is_debug {
472                                        println!(
473                                            "\t> ParamType::UNKNOWN '{}: {}'",
474                                            input_type.arg_name, input_type.arg_type
475                                        );
476                                    }
477                                }
478                            }
479                        }
480                    }
481                }
482                state.types.push('\n');
483                state.types.push_str(&hook.to_string());
484                state.hooks.push(hook);
485                state.types.push('\n');
486            }
487        }
488    }
489}
490
491#[derive(Clone)]
492pub struct QsyncInput {
493    path: PathBuf,
494    options: QsyncOptions,
495}
496
497impl QsyncInput {
498    pub fn new(path: PathBuf, options: QsyncOptions) -> Self {
499        Self { path, options }
500    }
501}
502
503// #[derive(Clone)]
504// /// Currently, only header extractors are supported.
505// pub enum Extractor {
506//     /// the generated code will require the user to provide a header
507//     /// with this name, and will update the request to include it
508//     Header(String),
509// }
510
511// #[derive(Clone)]
512// pub struct ExtractorProperties {
513//     /// The extractor's type.
514//     ///
515//     /// It must match exactly what you write in your endpoint function.
516//     ///
517//     /// In the example below, we use "ApiAuth" to extract some property from
518//     /// the request. "ApiAuth" is the type name.
519//     ///
520//     /// ```rust
521//     /// #[qsync]
522//     /// async fn endpoint(auth: path::to::ApiAuth) -> HttpResponse { ... }
523//     ///                                   -------
524//     ///                                   ^ this is the type_name
525//     /// ```
526//     pub type_name: String,
527//
528//     /// This is holds information about what it extracts from the request.
529//     /// This property determines how the generated code will change based
530//     /// on whether this extractor type is present.
531//     pub extracts: Extractor,
532// }
533
534#[derive(Clone)]
535pub struct QsyncOptions {
536    is_debug: bool,
537    url_base: String,
538
539    // Discarded this attempt; this is really hard to do correctly
540    // /// Qsync detects a set of "extractor" types that are generally
541    // /// found in web frameworks like actix_web. It uses these extractors
542    // /// to generate code with correct parameters and return types.
543    // /// By default, it supports:
544    // ///     - Path
545    // ///     - Json
546    // ///     - Form
547    // ///     - Query
548    // ///     - Auth
549    // ///
550    // /// In the case you write your own extractor, you can specify it here.
551    // custom_extractors: Vec<ExtractorProperties>,
552    auth_extractors: Vec<String>,
553}
554
555impl QsyncOptions {
556    pub fn new(is_debug: bool, url_base: String, auth_extractors: Vec<String>) -> Self {
557        Self {
558            is_debug,
559            url_base,
560            auth_extractors: auth_extractors,
561        }
562    }
563}
564
565pub fn process(input_paths: Vec<QsyncInput>, output_path: PathBuf) {
566    let mut state: BuildState = BuildState {
567        types: String::new(),
568        hooks: vec![],
569        unprocessed_files: Vec::<PathBuf>::new(),
570        is_debug: false, // we should remove this later on and have a global is_debug state, not BuildState-specific is_debug
571    };
572
573    state.types.push_str(
574        r#"/**
575    Hooks in this file were generated by create-rust-app's query-sync feature.
576
577    You likely have a `qsync.rs` binary in this project which you can use to
578    regenerate this file.
579    
580    To specify a specific a specific return type for a hook, use the
581    `#[qsync(return_type = "<typescript return type>")]` attribute above
582    your endpoint functions (which are decorated by the actix_web attributes
583    (#[get(...)], #[post(...)], etc).
584    
585    If it doesn't correctly guess whether it's a mutation or query based on
586    the actix_web attributes, then you can manually override that by specifying
587    the mutate property: `#[qsync(mutate=true)]`.
588*/
589"#,
590    );
591
592    state.types.push_str(
593        "import { UseQueryOptions, useMutation, useQuery, useQueryClient } from 'react-query'\n",
594    );
595
596    state
597        .types
598        .push_str("\nimport { useAuth } from './hooks/useAuth'\n");
599
600    for qsync_input in input_paths.clone() {
601        let input_path = qsync_input.path.clone();
602        let options = qsync_input.clone().options;
603        let is_debug = options.is_debug;
604        let endpoint_prefix = options.url_base;
605
606        if !input_path.exists() {
607            if is_debug {
608                println!("Path `{input_path:#?}` does not exist");
609            }
610
611            state.unprocessed_files.push(input_path);
612            continue;
613        }
614
615        let base_input_path = input_path.clone();
616
617        if input_path.clone().is_dir() {
618            for entry in WalkDir::new(input_path.clone()).sort_by_file_name() {
619                match entry {
620                    Ok(dir_entry) => {
621                        let path = dir_entry.into_path();
622
623                        // skip dir files because they're going to be recursively crawled by WalkDir
624                        if !path.is_dir() {
625                            // make sure it is a rust file
626                            let extension = path.extension();
627                            if extension.is_some() && extension.unwrap().eq_ignore_ascii_case("rs")
628                            {
629                                state.is_debug = is_debug; // this is a hack, we shouldn't have is_debug in the build state since it's global state rather than build/input-path specific state.
630                                process_service_file(
631                                    endpoint_prefix.clone(),
632                                    base_input_path.clone(),
633                                    path,
634                                    &mut state,
635                                    qsync_input.clone(),
636                                );
637                            } else if is_debug {
638                                println!("Encountered non-service or non-rust file `{path:#?}`");
639                            }
640                        } else if is_debug {
641                            println!("Encountered directory `{path:#?}`");
642                        }
643                    }
644                    Err(_) => {
645                        println!(
646                            "An error occurred whilst walking directory `{:#?}`...",
647                            input_path.clone()
648                        );
649                        continue;
650                    }
651                }
652            }
653        } else {
654            state.is_debug = is_debug;
655            process_service_file(
656                endpoint_prefix.clone(),
657                base_input_path,
658                input_path,
659                &mut state,
660                qsync_input.clone(),
661            );
662        }
663    }
664
665    let is_debug = input_paths
666        .clone()
667        .iter()
668        .any(|input| input.options.is_debug);
669    if is_debug {
670        println!("======================================");
671        println!("FINAL FILE:");
672        println!("======================================");
673        println!("{}", state.types);
674        println!("======================================");
675        println!("Note: Nothing is written in debug mode");
676        println!("======================================");
677    } else {
678        let mut file: File = File::create(&output_path).expect("Unable to write to file");
679        match file.write_all(state.types.as_bytes()) {
680            Ok(_) => println!("Successfully generated hooks, see {output_path:#?}"),
681            Err(_) => println!("Failed to generate types, an error occurred."),
682        }
683    }
684
685    if !state.unprocessed_files.is_empty() {
686        println!("Could not parse the following files:");
687    }
688
689    for unprocessed_file in state.unprocessed_files {
690        println!("• {unprocessed_file:#?}");
691    }
692}