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 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 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 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 {
316 pub types: String,
317 pub hooks: Vec<Hook>,
318 pub unprocessed_files: Vec<PathBuf>,
319 pub is_debug: bool, }
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 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 }
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)]
535pub struct QsyncOptions {
536 is_debug: bool,
537 url_base: String,
538
539 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, };
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 if !path.is_dir() {
625 let extension = path.extension();
627 if extension.is_some() && extension.unwrap().eq_ignore_ascii_case("rs")
628 {
629 state.is_debug = is_debug; 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}