vika_cli/generator/hooks/
react_query.rs

1use crate::error::Result;
2use crate::generator::api_client::{
3    extract_all_responses, extract_path_parameters, extract_query_parameters, extract_request_body,
4    ResponseInfo,
5};
6use crate::generator::hooks::context::HookContext;
7use crate::generator::hooks::HookFile;
8use crate::generator::swagger_parser::OperationInfo;
9use crate::generator::utils::{to_camel_case, to_pascal_case};
10use crate::templates::context::Parameter as ApiParameter;
11use crate::templates::engine::TemplateEngine;
12use crate::templates::registry::TemplateId;
13use openapiv3::OpenAPI;
14
15/// Generate React Query hooks from operations.
16#[allow(clippy::too_many_arguments)]
17pub fn generate_react_query_hooks(
18    openapi: &OpenAPI,
19    operations: &[OperationInfo],
20    module_name: &str,
21    spec_name: Option<&str>,
22    common_schemas: &[String],
23    enum_registry: &mut std::collections::HashMap<String, String>,
24    template_engine: &TemplateEngine,
25    apis_dir: Option<&str>,
26    schemas_dir: Option<&str>,
27    hooks_dir: Option<&str>,
28    query_keys_dir: Option<&str>,
29) -> Result<Vec<HookFile>> {
30    let mut hooks = Vec::new();
31
32    for op_info in operations {
33        let operation = &op_info.operation;
34        let method = op_info.method.to_uppercase();
35
36        // Determine if query or mutation
37        let is_query = matches!(method.as_str(), "GET" | "HEAD");
38        let is_mutation = matches!(method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
39
40        if !is_query && !is_mutation {
41            continue; // Skip unsupported methods
42        }
43
44        // Generate operation ID (function name)
45        let operation_id = if let Some(op_id) = &operation.operation_id {
46            to_camel_case(op_id)
47        } else {
48            generate_hook_name_from_path(&op_info.path, &op_info.method)
49        };
50
51        // Generate hook name
52        let hook_name = format!("use{}", to_pascal_case(&operation_id));
53
54        // Generate key name (same as operation_id for queries, but we still need it for mutations)
55        let key_name = operation_id.clone();
56
57        // Extract parameters
58        let path_params_info = extract_path_parameters(openapi, operation, enum_registry)?;
59        let query_params_info = extract_query_parameters(openapi, operation, enum_registry)?;
60        let request_body_info = extract_request_body(openapi, operation)?;
61        let all_responses = extract_all_responses(openapi, operation)?;
62
63        // Extract success and error responses
64        let success_responses: Vec<ResponseInfo> = all_responses
65            .iter()
66            .filter(|r| r.status_code >= 200 && r.status_code < 300)
67            .cloned()
68            .collect();
69        let _error_responses: Vec<ResponseInfo> = all_responses
70            .iter()
71            .filter(|r| r.status_code < 200 || r.status_code >= 300)
72            .cloned()
73            .collect();
74        let response_type = success_responses
75            .iter()
76            .find(|r| r.status_code == 200)
77            .map(|r| r.body_type.clone())
78            .unwrap_or_else(|| "any".to_string());
79
80        // Generate type names for success and error maps
81        let type_name_base = to_pascal_case(&operation_id);
82        let success_map_type = format!("{}Responses", type_name_base);
83        let error_map_type = format!("{}Errors", type_name_base);
84        let generic_result_type = format!("ApiResult<{}, {}>", success_map_type, error_map_type);
85
86        // Build parameter list for hook
87        let mut param_list_parts = Vec::new();
88        let mut param_names_parts = Vec::new();
89
90        // Add path parameters
91        for param in &path_params_info {
92            let param_type = match &param.param_type {
93                crate::generator::api_client::ParameterType::Enum(enum_name) => enum_name.clone(),
94                crate::generator::api_client::ParameterType::String => "string".to_string(),
95                crate::generator::api_client::ParameterType::Number => "number".to_string(),
96                crate::generator::api_client::ParameterType::Integer => "number".to_string(),
97                crate::generator::api_client::ParameterType::Boolean => "boolean".to_string(),
98                crate::generator::api_client::ParameterType::Array(_) => "string".to_string(),
99            };
100            param_list_parts.push(format!("{}: {}", param.name, param_type));
101            param_names_parts.push(param.name.clone());
102        }
103
104        // Collect enum types from query parameters for imports
105        let mut enum_types = Vec::new();
106        let namespace_name = to_pascal_case(&module_name.replace("/", "_"));
107
108        // Add query parameters (only for queries)
109        // Query params types are now in schema files, so use namespace-qualified types
110        if is_query && !query_params_info.is_empty() {
111            let mut query_fields = Vec::new();
112            for param in &query_params_info {
113                let param_type = match &param.param_type {
114                    crate::generator::api_client::ParameterType::Enum(enum_name) => {
115                        enum_types.push(enum_name.clone());
116                        // Use namespace-qualified enum name (e.g., Orders.SortByEnum)
117                        format!("{}.{}", namespace_name, enum_name)
118                    }
119                    crate::generator::api_client::ParameterType::Array(item_type) => {
120                        format!("{}[]", item_type)
121                    }
122                    crate::generator::api_client::ParameterType::String => "string".to_string(),
123                    crate::generator::api_client::ParameterType::Number => "number".to_string(),
124                    crate::generator::api_client::ParameterType::Integer => "number".to_string(),
125                    crate::generator::api_client::ParameterType::Boolean => "boolean".to_string(),
126                };
127                query_fields.push(format!("{}?: {}", param.name, param_type));
128            }
129            let query_type = format!("{{ {} }}", query_fields.join(", "));
130            param_list_parts.push(format!("query?: {}", query_type));
131            param_names_parts.push("query".to_string());
132        }
133
134        // For mutations, DO NOT add body parameter to hook signature
135        // Body parameter is passed via mutate(data) call, not as hook parameter
136        // Only path parameters should be in the hook signature
137
138        let param_list = param_list_parts.join(", ");
139        let param_names = param_names_parts.join(", ");
140
141        // Convert parameters to ApiParameter format
142        let path_params: Vec<ApiParameter> = path_params_info
143            .iter()
144            .map(|p| {
145                let param_type = match &p.param_type {
146                    crate::generator::api_client::ParameterType::Enum(enum_name) => {
147                        enum_name.clone()
148                    }
149                    crate::generator::api_client::ParameterType::Array(item_type) => {
150                        format!("{}[]", item_type)
151                    }
152                    crate::generator::api_client::ParameterType::String => "string".to_string(),
153                    crate::generator::api_client::ParameterType::Number => "number".to_string(),
154                    crate::generator::api_client::ParameterType::Integer => "number".to_string(),
155                    crate::generator::api_client::ParameterType::Boolean => "boolean".to_string(),
156                };
157                ApiParameter::new(p.name.clone(), param_type, false, p.description.clone())
158            })
159            .collect();
160
161        let query_params: Vec<ApiParameter> = query_params_info
162            .iter()
163            .map(|p| {
164                let param_type = match &p.param_type {
165                    crate::generator::api_client::ParameterType::Enum(enum_name) => {
166                        enum_name.clone()
167                    }
168                    crate::generator::api_client::ParameterType::Array(item_type) => {
169                        format!("{}[]", item_type)
170                    }
171                    crate::generator::api_client::ParameterType::String => "string".to_string(),
172                    crate::generator::api_client::ParameterType::Number => "number".to_string(),
173                    crate::generator::api_client::ParameterType::Integer => "number".to_string(),
174                    crate::generator::api_client::ParameterType::Boolean => "boolean".to_string(),
175                };
176                ApiParameter::new(p.name.clone(), param_type, true, p.description.clone())
177            })
178            .collect();
179
180        // Get body type for mutations
181        let body_type = request_body_info.as_ref().map(|(bt, _)| {
182            if bt == "any" {
183                "any".to_string()
184            } else if common_schemas.contains(bt) {
185                format!("Common.{}", bt)
186            } else {
187                let namespace_name = to_pascal_case(&module_name.replace("/", "_"));
188                format!("{}.{}", namespace_name, bt)
189            }
190        });
191
192        // Get description
193        let description = operation
194            .description
195            .clone()
196            .or_else(|| operation.summary.clone())
197            .filter(|s| !s.is_empty())
198            .unwrap_or_default();
199
200        // Build path parameter names (for mutations)
201        let path_param_names: Vec<String> =
202            path_params_info.iter().map(|p| p.name.clone()).collect();
203        let path_param_names_str = path_param_names.join(", ");
204
205        // Generate schema imports
206        let mut schema_imports = String::new();
207        let mut needs_common_import = false;
208        let mut needs_namespace_import = false;
209        let namespace_name = to_pascal_case(&module_name.replace("/", "_"));
210        let needs_enum_import = !enum_types.is_empty();
211
212        // Check if body type needs import
213        if let Some((body_type, _)) = &request_body_info {
214            if body_type != "any" {
215                if common_schemas.contains(body_type) {
216                    needs_common_import = true;
217                } else {
218                    needs_namespace_import = true;
219                }
220            }
221        }
222
223        // Calculate schema import path using actual schemas_dir from config
224        // From: src/hooks/{module}/useX.ts
225        // To: {schemas_dir}/{module}/index.ts
226        // Note: hooks_dir and schemas_dir don't include spec_name (it's in config if needed)
227        let module_depth = module_name.matches('/').count() + 1; // +1 for module directory
228        let hooks_depth = 1; // hooks directory
229        let total_depth = module_depth + hooks_depth;
230
231        let schemas_import_base = if let Some(schemas) = schemas_dir {
232            // Calculate relative path from hooks/{module}/ to {schemas_dir}/{module}/
233            let hooks_path = format!("src/hooks/{}", module_name);
234
235            let common_prefix = HookContext::find_common_prefix(&hooks_path, schemas);
236            let hooks_relative = hooks_path
237                .strip_prefix(&common_prefix)
238                .unwrap_or(&hooks_path)
239                .trim_start_matches('/');
240            let schemas_relative = schemas
241                .strip_prefix(&common_prefix)
242                .unwrap_or(schemas)
243                .trim_start_matches('/');
244
245            let hooks_depth_from_common = if hooks_relative.is_empty() {
246                0
247            } else {
248                hooks_relative.matches('/').count() + 1
249            };
250
251            if schemas_relative.is_empty() {
252                "../".repeat(hooks_depth_from_common)
253            } else {
254                format!(
255                    "{}{}",
256                    "../".repeat(hooks_depth_from_common),
257                    schemas_relative
258                )
259            }
260        } else {
261            // Fallback: assume schemas is at src/schemas/{spec}/{module}
262            format!("{}schemas", "../".repeat(total_depth))
263        };
264
265        // Check if schemas_dir includes spec_name
266        let schemas_dir_includes_spec =
267            if let (Some(schemas), Some(spec)) = (schemas_dir, spec_name) {
268                let schemas_normalized = schemas.trim_end_matches('/');
269                let spec_normalized = crate::generator::utils::sanitize_module_name(spec);
270                schemas_normalized.ends_with(&spec_normalized)
271                    || schemas_normalized.ends_with(&format!("/{}", spec_normalized))
272            } else {
273                false
274            };
275
276        if needs_common_import {
277            let common_import = if schemas_dir_includes_spec {
278                // Spec name is already in schemas_dir path
279                format!("{}/common", schemas_import_base.trim_end_matches('/'))
280            } else {
281                // Spec name is NOT in schemas_dir path, so schemas are at {schemas_dir}/common
282                // Don't add spec name to import path
283                format!("{}/common", schemas_import_base.trim_end_matches('/'))
284            };
285            schema_imports.push_str(&format!("import * as Common from \"{}\";", common_import));
286        }
287        if needs_namespace_import {
288            let sanitized_module_name = crate::generator::utils::sanitize_module_name(module_name);
289            let schemas_import = if schemas_dir_includes_spec {
290                // Spec name is already in schemas_dir path
291                format!(
292                    "{}/{}",
293                    schemas_import_base.trim_end_matches('/'),
294                    sanitized_module_name
295                )
296            } else {
297                // Spec name is NOT in schemas_dir path, so schemas are at {schemas_dir}/{module}
298                // Don't add spec name to import path
299                format!(
300                    "{}/{}",
301                    schemas_import_base.trim_end_matches('/'),
302                    sanitized_module_name
303                )
304            };
305            if !schema_imports.is_empty() {
306                schema_imports.push('\n');
307            }
308            schema_imports.push_str(&format!(
309                "import * as {} from \"{}\";",
310                namespace_name, schemas_import
311            ));
312        }
313
314        // Add enum type imports if we have any
315        // Enum types are now generated in schema files, so import from schemas
316        if needs_enum_import {
317            // Ensure we have namespace import for enums
318            if !needs_namespace_import {
319                let sanitized_module_name =
320                    crate::generator::utils::sanitize_module_name(module_name);
321                let schemas_import = if schemas_dir_includes_spec {
322                    format!(
323                        "{}/{}",
324                        schemas_import_base.trim_end_matches('/'),
325                        sanitized_module_name
326                    )
327                } else {
328                    // Spec name is NOT in schemas_dir path
329                    format!(
330                        "{}/{}",
331                        schemas_import_base.trim_end_matches('/'),
332                        sanitized_module_name
333                    )
334                };
335                if !schema_imports.is_empty() {
336                    schema_imports.push('\n');
337                }
338                schema_imports.push_str(&format!(
339                    "import * as {} from \"{}\";",
340                    namespace_name, schemas_import
341                ));
342            }
343            // Enums are now imported via namespace (e.g., Orders.SortByEnum)
344            // No need for separate enum import since they're in the namespace
345        }
346
347        // Build hook context
348        let context = HookContext {
349            hook_name: hook_name.clone(),
350            key_name,
351            operation_id,
352            http_method: op_info.method.clone(),
353            path: op_info.path.clone(),
354            path_params,
355            query_params,
356            body_type,
357            response_type,
358            module_name: module_name.to_string(),
359            spec_name: spec_name.map(|s| s.to_string()),
360            api_import_path: HookContext::calculate_api_import_path(
361                module_name,
362                spec_name,
363                apis_dir,
364            ),
365            query_keys_import_path: HookContext::calculate_query_keys_import_path(
366                module_name,
367                spec_name,
368                hooks_dir,
369                query_keys_dir,
370            ),
371            param_list,
372            param_names,
373            path_param_names: path_param_names_str,
374            schema_imports,
375            description,
376            success_map_type,
377            error_map_type,
378            generic_result_type,
379            import_runtime_path: HookContext::calculate_runtime_import_path(module_name, spec_name),
380        };
381
382        // Render template
383        let template_id = if is_query {
384            TemplateId::ReactQueryQuery
385        } else {
386            TemplateId::ReactQueryMutation
387        };
388
389        let content = template_engine.render(template_id, &context)?;
390
391        // Generate filename
392        let filename = format!("{}.ts", hook_name);
393
394        hooks.push(HookFile { filename, content });
395    }
396
397    Ok(hooks)
398}
399
400/// Generate hook name from path and method (fallback when operation_id is missing).
401fn generate_hook_name_from_path(path: &str, method: &str) -> String {
402    let path_parts: Vec<&str> = path
403        .trim_start_matches('/')
404        .split('/')
405        .filter(|p| !p.starts_with('{'))
406        .collect();
407
408    let method_upper = method.to_uppercase();
409    let method_prefix = match method_upper.as_str() {
410        "GET" => "get",
411        "POST" => "create",
412        "PUT" => "update",
413        "DELETE" => "delete",
414        "PATCH" => "patch",
415        _ => {
416            return to_camel_case(&method.to_lowercase());
417        }
418    };
419
420    let base_name = if path_parts.is_empty() {
421        method_prefix.to_string()
422    } else {
423        let resource_name = if path_parts.len() > 1 {
424            path_parts.last().unwrap_or(&"")
425        } else {
426            path_parts.first().unwrap_or(&"")
427        };
428
429        if resource_name.ends_with('s') && path.contains('{') {
430            let singular = &resource_name[..resource_name.len() - 1];
431            format!("{}{}ById", method_prefix, to_pascal_case(singular))
432        } else if path.contains('{') {
433            format!("{}{}ById", method_prefix, to_pascal_case(resource_name))
434        } else {
435            format!("{}{}", method_prefix, to_pascal_case(resource_name))
436        }
437    };
438
439    to_camel_case(&base_name)
440}