pecto_typescript/extractors/
controller.rs1use super::common::*;
2use crate::context::ParsedFile;
3use pecto_core::model::*;
4
5pub fn extract(file: &ParsedFile) -> Option<Capability> {
7 let root = file.tree.root_node();
8 let source = file.source.as_bytes();
9 let full_text = &file.source;
10
11 let mut endpoints = Vec::new();
12
13 extract_nestjs_endpoints(&root, source, &mut endpoints);
15
16 extract_express_endpoints(full_text, &mut endpoints);
18
19 if file.path.contains("route.") {
21 extract_nextjs_endpoints(&root, source, file, &mut endpoints);
22 }
23
24 if endpoints.is_empty() {
25 return None;
26 }
27
28 let file_stem = file
29 .path
30 .rsplit('/')
31 .next()
32 .unwrap_or(&file.path)
33 .split('.')
34 .next()
35 .unwrap_or("unknown");
36 let capability_name = to_kebab_case(
37 &file_stem
38 .replace(".controller", "")
39 .replace(".routes", "")
40 .replace(".router", "")
41 .replace("route", "api"),
42 );
43
44 let mut capability = Capability::new(capability_name, file.path.clone());
45 capability.endpoints = endpoints;
46 Some(capability)
47}
48
49fn extract_nestjs_endpoints(
52 _root: &tree_sitter::Node,
53 source: &[u8],
54 endpoints: &mut Vec<Endpoint>,
55) {
56 let full_text = std::str::from_utf8(source).unwrap_or("");
57 if !full_text.contains("@Controller") && !full_text.contains("@Get") {
59 return;
60 }
61
62 let base_path = full_text
64 .split("@Controller(")
65 .nth(1)
66 .and_then(|s| s.split(')').next())
67 .map(|s| clean_string_literal(s.trim()))
68 .unwrap_or_default();
69
70 let methods = [
72 ("@Get(", HttpMethod::Get),
73 ("@Post(", HttpMethod::Post),
74 ("@Put(", HttpMethod::Put),
75 ("@Delete(", HttpMethod::Delete),
76 ("@Patch(", HttpMethod::Patch),
77 ];
78
79 for (marker, http_method) in &methods {
80 let mut remaining = full_text;
81 while let Some(pos) = remaining.find(marker) {
82 let after = &remaining[pos + marker.len()..];
83 let method_path = after
84 .split(')')
85 .next()
86 .map(|s| clean_string_literal(s.trim()))
87 .unwrap_or_default();
88
89 let full_path = if base_path.is_empty() && method_path.is_empty() {
90 "/".to_string()
91 } else if method_path.is_empty() {
92 format!("/{}", base_path)
93 } else if base_path.is_empty() {
94 format!("/{}", method_path.trim_start_matches('/'))
95 } else {
96 format!(
97 "/{}/{}",
98 base_path.trim_matches('/'),
99 method_path.trim_start_matches('/')
100 )
101 };
102
103 let normalized = normalize_path_params(&full_path);
104
105 let security = if full_text.contains("@UseGuards") {
107 Some(SecurityConfig {
108 authentication: Some("required".to_string()),
109 roles: Vec::new(),
110 rate_limit: None,
111 cors: None,
112 })
113 } else {
114 None
115 };
116
117 endpoints.push(Endpoint {
118 method: *http_method,
119 path: normalized,
120 input: None,
121 validation: Vec::new(),
122 behaviors: vec![Behavior {
123 name: "success".to_string(),
124 condition: None,
125 returns: ResponseSpec {
126 status: default_status(http_method),
127 body: None,
128 },
129 side_effects: Vec::new(),
130 }],
131 security,
132 });
133
134 remaining = &remaining[pos + marker.len()..];
135 }
136 }
137}
138
139fn extract_express_endpoints(source: &str, endpoints: &mut Vec<Endpoint>) {
142 for line in source.lines() {
143 let trimmed = line.trim();
144
145 let method = if trimmed.contains(".get(") || trimmed.contains(".GET(") {
147 Some(HttpMethod::Get)
148 } else if trimmed.contains(".post(") || trimmed.contains(".POST(") {
149 Some(HttpMethod::Post)
150 } else if trimmed.contains(".put(") || trimmed.contains(".PUT(") {
151 Some(HttpMethod::Put)
152 } else if trimmed.contains(".delete(") || trimmed.contains(".DELETE(") {
153 Some(HttpMethod::Delete)
154 } else if trimmed.contains(".patch(") || trimmed.contains(".PATCH(") {
155 Some(HttpMethod::Patch)
156 } else {
157 None
158 };
159
160 let Some(http_method) = method else {
161 continue;
162 };
163
164 if !trimmed.contains('"') && !trimmed.contains('\'') && !trimmed.contains('`') {
166 continue;
167 }
168
169 let has_route_prefix = trimmed.contains("router.")
171 || trimmed.contains("app.")
172 || trimmed.contains("route.")
173 || trimmed.contains("server.");
174 if !has_route_prefix {
175 continue;
176 }
177
178 if let Some(path) = extract_string_arg(trimmed)
180 && path.starts_with('/')
181 {
182 let normalized = normalize_path_params(&path);
183 endpoints.push(Endpoint {
184 method: http_method,
185 path: normalized,
186 input: None,
187 validation: Vec::new(),
188 behaviors: vec![Behavior {
189 name: "success".to_string(),
190 condition: None,
191 returns: ResponseSpec {
192 status: default_status(&http_method),
193 body: None,
194 },
195 side_effects: Vec::new(),
196 }],
197 security: None,
198 });
199 }
200 }
201}
202
203fn extract_nextjs_endpoints(
206 root: &tree_sitter::Node,
207 source: &[u8],
208 file: &ParsedFile,
209 endpoints: &mut Vec<Endpoint>,
210) {
211 let path = derive_nextjs_path(&file.path);
213
214 find_exported_functions(root, source, &path, endpoints);
216}
217
218fn derive_nextjs_path(file_path: &str) -> String {
219 let start = file_path.find("app/").map(|i| i + 4).unwrap_or(0);
221 let path_part = &file_path[start..];
222
223 let dir = path_part
225 .rsplit('/')
226 .skip(1)
227 .collect::<Vec<_>>()
228 .into_iter()
229 .rev()
230 .collect::<Vec<_>>()
231 .join("/");
232
233 let mut result = String::from("/");
235 result.push_str(&dir.replace('[', "{").replace(']', "}"));
236 result
237}
238
239fn find_exported_functions(
240 node: &tree_sitter::Node,
241 source: &[u8],
242 path: &str,
243 endpoints: &mut Vec<Endpoint>,
244) {
245 for i in 0..node.named_child_count() {
246 let child = node.named_child(i).unwrap();
247
248 if child.kind() == "export_statement" {
249 let text = node_text(&child, source);
250 let methods = [
251 ("GET", HttpMethod::Get),
252 ("POST", HttpMethod::Post),
253 ("PUT", HttpMethod::Put),
254 ("DELETE", HttpMethod::Delete),
255 ("PATCH", HttpMethod::Patch),
256 ];
257
258 for (name, method) in &methods {
259 if text.contains(&format!("function {}", name))
260 || text.contains(&format!("const {} ", name))
261 || text.contains(&format!("async function {}", name))
262 {
263 endpoints.push(Endpoint {
264 method: *method,
265 path: path.to_string(),
266 input: None,
267 validation: Vec::new(),
268 behaviors: vec![Behavior {
269 name: "success".to_string(),
270 condition: None,
271 returns: ResponseSpec {
272 status: default_status(method),
273 body: None,
274 },
275 side_effects: Vec::new(),
276 }],
277 security: None,
278 });
279 }
280 }
281 }
282 }
283}
284
285fn extract_string_arg(line: &str) -> Option<String> {
288 for delim in ['"', '\'', '`'] {
290 if let Some(start) = line.find(delim)
291 && let Some(end) = line[start + 1..].find(delim)
292 {
293 return Some(line[start + 1..start + 1 + end].to_string());
294 }
295 }
296 None
297}
298
299fn normalize_path_params(path: &str) -> String {
301 let mut result = String::new();
302 let mut chars = path.chars().peekable();
303 while let Some(c) = chars.next() {
304 if c == ':' {
305 result.push('{');
306 while let Some(&next) = chars.peek() {
307 if next.is_alphanumeric() || next == '_' {
308 result.push(chars.next().unwrap());
309 } else {
310 break;
311 }
312 }
313 result.push('}');
314 } else {
315 result.push(c);
316 }
317 }
318 result
319}
320
321fn default_status(method: &HttpMethod) -> u16 {
322 match method {
323 HttpMethod::Post => 201,
324 HttpMethod::Delete => 204,
325 _ => 200,
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::context::ParsedFile;
333
334 fn parse_file(source: &str, path: &str) -> ParsedFile {
335 ParsedFile::parse(source.to_string(), path.to_string()).unwrap()
336 }
337
338 #[test]
339 fn test_express_routes() {
340 let source = r#"
341const express = require('express');
342const router = express.Router();
343
344router.get('/users', (req, res) => {
345 res.json(users);
346});
347
348router.post('/users', (req, res) => {
349 res.status(201).json(user);
350});
351
352router.get('/users/:id', (req, res) => {
353 res.json(user);
354});
355
356router.delete('/users/:id', (req, res) => {
357 res.status(204).send();
358});
359
360module.exports = router;
361"#;
362
363 let file = parse_file(source, "routes/users.ts");
364 let capability = extract(&file).unwrap();
365
366 assert_eq!(capability.endpoints.len(), 4);
367 assert!(matches!(capability.endpoints[0].method, HttpMethod::Get));
368 assert_eq!(capability.endpoints[0].path, "/users");
369 assert_eq!(capability.endpoints[2].path, "/users/{id}");
370 assert!(matches!(capability.endpoints[3].method, HttpMethod::Delete));
371 }
372
373 #[test]
374 fn test_nestjs_controller() {
375 let source = r#"
376import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
377
378@Controller('users')
379export class UsersController {
380 @Get()
381 findAll() {
382 return this.usersService.findAll();
383 }
384
385 @Get(':id')
386 findOne(@Param('id') id: string) {
387 return this.usersService.findOne(id);
388 }
389
390 @Post()
391 @UseGuards(AuthGuard)
392 create(@Body() dto: CreateUserDto) {
393 return this.usersService.create(dto);
394 }
395}
396"#;
397
398 let file = parse_file(source, "users.controller.ts");
399 let capability = extract(&file);
400
401 assert!(
402 capability.is_some(),
403 "Should find NestJS controller capability"
404 );
405 let cap = capability.unwrap();
406 assert!(
407 cap.endpoints.len() >= 3,
408 "Should find 3 endpoints, found {}",
409 cap.endpoints.len()
410 );
411 }
412
413 #[test]
414 fn test_nextjs_route() {
415 let source = r#"
416import { NextResponse } from 'next/server';
417
418export async function GET(request: Request) {
419 return NextResponse.json({ users: [] });
420}
421
422export async function POST(request: Request) {
423 const body = await request.json();
424 return NextResponse.json(body, { status: 201 });
425}
426"#;
427
428 let file = parse_file(source, "app/api/users/route.ts");
429 let capability = extract(&file).unwrap();
430
431 assert_eq!(capability.endpoints.len(), 2);
432 assert!(matches!(capability.endpoints[0].method, HttpMethod::Get));
433 assert_eq!(capability.endpoints[0].path, "/api/users");
434 assert!(matches!(capability.endpoints[1].method, HttpMethod::Post));
435 }
436
437 #[test]
438 fn test_nextjs_dynamic_route() {
439 let source = r#"
440export async function GET(request: Request, { params }: { params: { id: string } }) {
441 return NextResponse.json({ id: params.id });
442}
443
444export async function DELETE(request: Request, { params }: { params: { id: string } }) {
445 return new Response(null, { status: 204 });
446}
447"#;
448
449 let file = parse_file(source, "src/app/api/users/[id]/route.ts");
450 let capability = extract(&file).unwrap();
451
452 assert_eq!(capability.endpoints.len(), 2);
453 assert_eq!(capability.endpoints[0].path, "/api/users/{id}");
454 }
455
456 #[test]
457 fn test_no_routes() {
458 let source = r#"
459export function helper() {
460 return 42;
461}
462
463class Utils {
464 static format(s: string) { return s; }
465}
466"#;
467 let file = parse_file(source, "utils.ts");
468 assert!(extract(&file).is_none());
469 }
470}