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.
16pub fn generate_react_query_hooks(
17    openapi: &OpenAPI,
18    operations: &[OperationInfo],
19    module_name: &str,
20    spec_name: Option<&str>,
21    common_schemas: &[String],
22    enum_registry: &mut std::collections::HashMap<String, String>,
23    template_engine: &TemplateEngine,
24) -> Result<Vec<HookFile>> {
25    let mut hooks = Vec::new();
26
27    for op_info in operations {
28        let operation = &op_info.operation;
29        let method = op_info.method.to_uppercase();
30
31        // Determine if query or mutation
32        let is_query = matches!(method.as_str(), "GET" | "HEAD");
33        let is_mutation = matches!(method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
34
35        if !is_query && !is_mutation {
36            continue; // Skip unsupported methods
37        }
38
39        // Generate operation ID (function name)
40        let operation_id = if let Some(op_id) = &operation.operation_id {
41            to_camel_case(op_id)
42        } else {
43            generate_hook_name_from_path(&op_info.path, &op_info.method)
44        };
45
46        // Generate hook name
47        let hook_name = format!("use{}", to_pascal_case(&operation_id));
48
49        // Generate key name (same as operation_id for queries, but we still need it for mutations)
50        let key_name = operation_id.clone();
51
52        // Extract parameters
53        let path_params_info = extract_path_parameters(openapi, operation, enum_registry)?;
54        let query_params_info = extract_query_parameters(openapi, operation, enum_registry)?;
55        let request_body_info = extract_request_body(openapi, operation)?;
56        let all_responses = extract_all_responses(openapi, operation)?;
57
58        // Get response type
59        let success_responses: Vec<ResponseInfo> = all_responses
60            .iter()
61            .filter(|r| r.status_code >= 200 && r.status_code < 300)
62            .cloned()
63            .collect();
64        let response_type = success_responses
65            .iter()
66            .find(|r| r.status_code == 200)
67            .map(|r| r.body_type.clone())
68            .unwrap_or_else(|| "any".to_string());
69
70        // Build parameter list for hook
71        let mut param_list_parts = Vec::new();
72        let mut param_names_parts = Vec::new();
73
74        // Add path parameters
75        for param in &path_params_info {
76            let param_type = match &param.param_type {
77                crate::generator::api_client::ParameterType::Enum(enum_name) => enum_name.clone(),
78                crate::generator::api_client::ParameterType::String => "string".to_string(),
79                crate::generator::api_client::ParameterType::Number => "number".to_string(),
80                crate::generator::api_client::ParameterType::Integer => "number".to_string(),
81                crate::generator::api_client::ParameterType::Boolean => "boolean".to_string(),
82                crate::generator::api_client::ParameterType::Array(_) => "string".to_string(),
83            };
84            param_list_parts.push(format!("{}: {}", param.name, param_type));
85            param_names_parts.push(param.name.clone());
86        }
87
88        // Collect enum types from query parameters for imports
89        let mut enum_types = Vec::new();
90
91        // Add query parameters (only for queries)
92        if is_query && !query_params_info.is_empty() {
93            let mut query_fields = Vec::new();
94            for param in &query_params_info {
95                let param_type = match &param.param_type {
96                    crate::generator::api_client::ParameterType::Enum(enum_name) => {
97                        enum_types.push(enum_name.clone());
98                        enum_name.clone()
99                    }
100                    crate::generator::api_client::ParameterType::Array(item_type) => {
101                        format!("{}[]", item_type)
102                    }
103                    crate::generator::api_client::ParameterType::String => "string".to_string(),
104                    crate::generator::api_client::ParameterType::Number => "number".to_string(),
105                    crate::generator::api_client::ParameterType::Integer => "number".to_string(),
106                    crate::generator::api_client::ParameterType::Boolean => "boolean".to_string(),
107                };
108                query_fields.push(format!("{}?: {}", param.name, param_type));
109            }
110            let query_type = format!("{{ {} }}", query_fields.join(", "));
111            param_list_parts.push(format!("query?: {}", query_type));
112            param_names_parts.push("query".to_string());
113        }
114
115        // For mutations, DO NOT add body parameter to hook signature
116        // Body parameter is passed via mutate(data) call, not as hook parameter
117        // Only path parameters should be in the hook signature
118
119        let param_list = param_list_parts.join(", ");
120        let param_names = param_names_parts.join(", ");
121
122        // Convert parameters to ApiParameter format
123        let path_params: Vec<ApiParameter> = path_params_info
124            .iter()
125            .map(|p| {
126                let param_type = match &p.param_type {
127                    crate::generator::api_client::ParameterType::Enum(enum_name) => {
128                        enum_name.clone()
129                    }
130                    crate::generator::api_client::ParameterType::Array(item_type) => {
131                        format!("{}[]", item_type)
132                    }
133                    crate::generator::api_client::ParameterType::String => "string".to_string(),
134                    crate::generator::api_client::ParameterType::Number => "number".to_string(),
135                    crate::generator::api_client::ParameterType::Integer => "number".to_string(),
136                    crate::generator::api_client::ParameterType::Boolean => "boolean".to_string(),
137                };
138                ApiParameter::new(p.name.clone(), param_type, false, p.description.clone())
139            })
140            .collect();
141
142        let query_params: Vec<ApiParameter> = query_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, true, p.description.clone())
158            })
159            .collect();
160
161        // Get body type for mutations
162        let body_type = request_body_info.as_ref().map(|(bt, _)| {
163            if bt == "any" {
164                "any".to_string()
165            } else if common_schemas.contains(bt) {
166                format!("Common.{}", bt)
167            } else {
168                let namespace_name = to_pascal_case(&module_name.replace("/", "_"));
169                format!("{}.{}", namespace_name, bt)
170            }
171        });
172
173        // Get description
174        let description = operation
175            .description
176            .clone()
177            .or_else(|| operation.summary.clone())
178            .filter(|s| !s.is_empty())
179            .unwrap_or_default();
180
181        // Build path parameter names (for mutations)
182        let path_param_names: Vec<String> =
183            path_params_info.iter().map(|p| p.name.clone()).collect();
184        let path_param_names_str = path_param_names.join(", ");
185
186        // Generate schema imports
187        let mut schema_imports = String::new();
188        let mut needs_common_import = false;
189        let mut needs_namespace_import = false;
190        let namespace_name = to_pascal_case(&module_name.replace("/", "_"));
191        let needs_enum_import = !enum_types.is_empty();
192
193        // Check if body type needs import
194        if let Some((body_type, _)) = &request_body_info {
195            if body_type != "any" {
196                if common_schemas.contains(body_type) {
197                    needs_common_import = true;
198                } else {
199                    needs_namespace_import = true;
200                }
201            }
202        }
203
204        // Calculate schema import depth
205        // From: src/hooks/{spec}/{module}/useX.ts
206        // To: src/schemas/{spec}/{module}/
207        // Structure: hooks/{spec}/{module}/useX.ts -> go up to hooks/, then to src/, then into schemas/
208        let module_depth = module_name.matches('/').count() + 1; // +1 for module directory
209        let spec_depth = if spec_name.is_some() { 1 } else { 0 };
210        let total_depth = module_depth + spec_depth + 1; // +1 for hooks directory
211        let schemas_depth = total_depth; // hooks/ and schemas/ are both under src/, so same depth
212
213        if needs_common_import {
214            let common_import = if let Some(spec) = spec_name {
215                format!(
216                    "{}schemas/{}/common",
217                    "../".repeat(schemas_depth),
218                    crate::generator::utils::sanitize_module_name(spec)
219                )
220            } else {
221                format!("{}schemas/common", "../".repeat(schemas_depth))
222            };
223            schema_imports.push_str(&format!("import * as Common from \"{}\";", common_import));
224        }
225        if needs_namespace_import {
226            let sanitized_module_name = crate::generator::utils::sanitize_module_name(module_name);
227            let schemas_import = if let Some(spec) = spec_name {
228                format!(
229                    "{}schemas/{}/{}",
230                    "../".repeat(schemas_depth),
231                    crate::generator::utils::sanitize_module_name(spec),
232                    sanitized_module_name
233                )
234            } else {
235                format!(
236                    "{}schemas/{}",
237                    "../".repeat(schemas_depth),
238                    sanitized_module_name
239                )
240            };
241            if !schema_imports.is_empty() {
242                schema_imports.push('\n');
243            }
244            schema_imports.push_str(&format!(
245                "import * as {} from \"{}\";",
246                namespace_name, schemas_import
247            ));
248        }
249
250        // Add enum type imports if we have any
251        if needs_enum_import {
252            let sanitized_module_name = crate::generator::utils::sanitize_module_name(module_name);
253            let schemas_import = if let Some(spec) = spec_name {
254                format!(
255                    "{}schemas/{}/{}",
256                    "../".repeat(schemas_depth),
257                    crate::generator::utils::sanitize_module_name(spec),
258                    sanitized_module_name
259                )
260            } else {
261                format!(
262                    "{}schemas/{}",
263                    "../".repeat(schemas_depth),
264                    sanitized_module_name
265                )
266            };
267            if !schema_imports.is_empty() {
268                schema_imports.push('\n');
269            }
270            let enum_names: Vec<String> = enum_types.to_vec();
271            schema_imports.push_str(&format!(
272                "import type {{ {} }} from \"{}\";",
273                enum_names.join(", "),
274                schemas_import
275            ));
276        }
277
278        // Build hook context
279        let context = HookContext {
280            hook_name: hook_name.clone(),
281            key_name,
282            operation_id,
283            http_method: op_info.method.clone(),
284            path: op_info.path.clone(),
285            path_params,
286            query_params,
287            body_type,
288            response_type,
289            module_name: module_name.to_string(),
290            spec_name: spec_name.map(|s| s.to_string()),
291            api_import_path: HookContext::calculate_api_import_path(module_name, spec_name),
292            query_keys_import_path: HookContext::calculate_query_keys_import_path(
293                module_name,
294                spec_name,
295            ),
296            param_list,
297            param_names,
298            path_param_names: path_param_names_str,
299            schema_imports,
300            description,
301        };
302
303        // Render template
304        let template_id = if is_query {
305            TemplateId::ReactQueryQuery
306        } else {
307            TemplateId::ReactQueryMutation
308        };
309
310        let content = template_engine.render(template_id, &context)?;
311
312        // Generate filename
313        let filename = format!("{}.ts", hook_name);
314
315        hooks.push(HookFile { filename, content });
316    }
317
318    Ok(hooks)
319}
320
321/// Generate hook name from path and method (fallback when operation_id is missing).
322fn generate_hook_name_from_path(path: &str, method: &str) -> String {
323    let path_parts: Vec<&str> = path
324        .trim_start_matches('/')
325        .split('/')
326        .filter(|p| !p.starts_with('{'))
327        .collect();
328
329    let method_upper = method.to_uppercase();
330    let method_prefix = match method_upper.as_str() {
331        "GET" => "get",
332        "POST" => "create",
333        "PUT" => "update",
334        "DELETE" => "delete",
335        "PATCH" => "patch",
336        _ => {
337            return to_camel_case(&method.to_lowercase());
338        }
339    };
340
341    let base_name = if path_parts.is_empty() {
342        method_prefix.to_string()
343    } else {
344        let resource_name = if path_parts.len() > 1 {
345            path_parts.last().unwrap_or(&"")
346        } else {
347            path_parts.first().unwrap_or(&"")
348        };
349
350        if resource_name.ends_with('s') && path.contains('{') {
351            let singular = &resource_name[..resource_name.len() - 1];
352            format!("{}{}ById", method_prefix, to_pascal_case(singular))
353        } else if path.contains('{') {
354            format!("{}{}ById", method_prefix, to_pascal_case(resource_name))
355        } else {
356            format!("{}{}", method_prefix, to_pascal_case(resource_name))
357        }
358    };
359
360    to_camel_case(&base_name)
361}