@typokit/plugin-axum 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1111 @@
1
+ use std::collections::{BTreeMap, BTreeSet, HashMap};
2
+
3
+ use typokit_transform_native::route_compiler::{PathSegment, RouteEntry};
4
+ use typokit_transform_native::type_extractor::TypeMetadata;
5
+ use super::GeneratedOutput;
6
+
7
+ /// Info about a handler action derived from a route.
8
+ struct HandlerAction {
9
+ action: String,
10
+ has_path_param: bool,
11
+ }
12
+
13
+ /// Resolved entity information for handler generation.
14
+ struct EntityInfo {
15
+ /// PascalCase entity name (e.g., "User")
16
+ name: String,
17
+ /// snake_case entity name (e.g., "user")
18
+ snake_name: String,
19
+ /// Whether this entity has @table annotation (repository functions exist)
20
+ is_table: bool,
21
+ /// Rust type for the ID field in extractors (e.g., "String", "i64")
22
+ id_type: String,
23
+ /// Input struct name for create/update (e.g., "UserWithoutId")
24
+ input_struct: String,
25
+ }
26
+
27
+ /// Generate per-entity handler files and a handlers/mod.rs from routes.
28
+ ///
29
+ /// Produces:
30
+ /// - `src/handlers/{entity}.rs` per entity with handler functions (overwrite: false)
31
+ /// - `src/handlers/mod.rs` with pub mod declarations (overwrite: true)
32
+ pub fn generate_handlers(
33
+ type_map: &HashMap<String, TypeMetadata>,
34
+ routes: &[RouteEntry],
35
+ ) -> Vec<GeneratedOutput> {
36
+ let mut outputs = Vec::new();
37
+
38
+ let entity_groups = group_routes_by_entity(routes);
39
+
40
+ let mut module_names: Vec<String> = Vec::new();
41
+
42
+ for (module_name, raw_prefix, actions) in &entity_groups {
43
+ module_names.push(module_name.clone());
44
+ let entity_info = resolve_entity_info(raw_prefix, type_map);
45
+ let content = generate_entity_handler_file(&entity_info, actions);
46
+ outputs.push(GeneratedOutput {
47
+ path: format!("src/handlers/{}.rs", module_name),
48
+ content,
49
+ overwrite: false,
50
+ });
51
+ }
52
+
53
+ module_names.sort();
54
+ outputs.push(generate_handlers_mod(&module_names));
55
+
56
+ outputs
57
+ }
58
+
59
+ /// Group routes by entity module and collect handler actions.
60
+ ///
61
+ /// Returns Vec of (module_name, raw_prefix, actions).
62
+ /// Uses BTreeMap for deterministic ordering.
63
+ fn group_routes_by_entity(
64
+ routes: &[RouteEntry],
65
+ ) -> Vec<(String, String, Vec<HandlerAction>)> {
66
+ let mut groups: BTreeMap<String, (String, Vec<HandlerAction>)> = BTreeMap::new();
67
+
68
+ let mut sorted_routes: Vec<&RouteEntry> = routes.iter().collect();
69
+ sorted_routes.sort_by(|a, b| {
70
+ a.handler_ref
71
+ .cmp(&b.handler_ref)
72
+ .then(a.method.cmp(&b.method))
73
+ });
74
+
75
+ for route in sorted_routes {
76
+ let (module_name, raw_prefix) = derive_module_info(&route.handler_ref);
77
+ let action = derive_action_name(route);
78
+ let has_path_param = route
79
+ .segments
80
+ .iter()
81
+ .any(|s| matches!(s, PathSegment::Param(_)));
82
+
83
+ groups
84
+ .entry(module_name)
85
+ .or_insert_with(|| (raw_prefix, Vec::new()))
86
+ .1
87
+ .push(HandlerAction {
88
+ action,
89
+ has_path_param,
90
+ });
91
+ }
92
+
93
+ groups
94
+ .into_iter()
95
+ .map(|(module, (prefix, actions))| (module, prefix, actions))
96
+ .collect()
97
+ }
98
+
99
+ /// Derive module name and raw prefix from a handler_ref string.
100
+ ///
101
+ /// "UsersRoutes#GET /users" → module = "users", prefix = "Users"
102
+ fn derive_module_info(handler_ref: &str) -> (String, String) {
103
+ let contract_name = handler_ref.split('#').next().unwrap_or(handler_ref);
104
+ let raw_prefix = contract_name
105
+ .strip_suffix("Routes")
106
+ .or_else(|| contract_name.strip_suffix("Route"))
107
+ .unwrap_or(contract_name)
108
+ .to_string();
109
+ let module_name = to_snake_case(&raw_prefix);
110
+ (module_name, raw_prefix)
111
+ }
112
+
113
+ /// Derive handler action name from route method and path.
114
+ ///
115
+ /// Follows REST conventions identical to router.rs.
116
+ fn derive_action_name(route: &RouteEntry) -> String {
117
+ let has_param = route
118
+ .segments
119
+ .iter()
120
+ .any(|s| matches!(s, PathSegment::Param(_)));
121
+ match (route.method.as_str(), has_param) {
122
+ ("GET", false) => "list".to_string(),
123
+ ("GET", true) => "get_by_id".to_string(),
124
+ ("POST", false) => "create".to_string(),
125
+ ("PUT", true) | ("PATCH", true) => "update".to_string(),
126
+ ("DELETE", true) => "delete".to_string(),
127
+ (method, _) => method.to_lowercase(),
128
+ }
129
+ }
130
+
131
+ /// Resolve entity info from the raw prefix (e.g., "Users") using type_map.
132
+ fn resolve_entity_info(
133
+ raw_prefix: &str,
134
+ type_map: &HashMap<String, TypeMetadata>,
135
+ ) -> EntityInfo {
136
+ let entity_name = resolve_entity_name(raw_prefix, type_map);
137
+ let snake_name = to_snake_case(&entity_name);
138
+
139
+ let (is_table, id_type) = if let Some(meta) = type_map.get(&entity_name) {
140
+ (is_table_entity(meta), find_id_type(meta))
141
+ } else {
142
+ (false, "String".to_string())
143
+ };
144
+
145
+ let input_struct = format!("{}WithoutId", entity_name);
146
+
147
+ EntityInfo {
148
+ name: entity_name,
149
+ snake_name,
150
+ is_table,
151
+ id_type,
152
+ input_struct,
153
+ }
154
+ }
155
+
156
+ /// Find entity name in type_map from the raw prefix (e.g., "Users" → "User").
157
+ fn resolve_entity_name(raw_prefix: &str, type_map: &HashMap<String, TypeMetadata>) -> String {
158
+ // Exact match
159
+ if type_map.contains_key(raw_prefix) {
160
+ return raw_prefix.to_string();
161
+ }
162
+
163
+ // Singular: strip trailing 's'
164
+ if let Some(singular) = raw_prefix.strip_suffix('s') {
165
+ if !singular.is_empty() && type_map.contains_key(singular) {
166
+ return singular.to_string();
167
+ }
168
+ }
169
+
170
+ // "ies" → "y" (e.g., "Categories" → "Category")
171
+ if let Some(stem) = raw_prefix.strip_suffix("ies") {
172
+ let singular = format!("{}y", stem);
173
+ if type_map.contains_key(&singular) {
174
+ return singular;
175
+ }
176
+ }
177
+
178
+ // Fallback: best-effort singular
179
+ raw_prefix
180
+ .strip_suffix('s')
181
+ .filter(|s| !s.is_empty())
182
+ .unwrap_or(raw_prefix)
183
+ .to_string()
184
+ }
185
+
186
+ /// Check if a TypeMetadata has the @table JSDoc annotation.
187
+ fn is_table_entity(meta: &TypeMetadata) -> bool {
188
+ meta.jsdoc
189
+ .as_ref()
190
+ .map(|j| j.contains_key("table"))
191
+ .unwrap_or(false)
192
+ }
193
+
194
+ /// Find the Rust type for the @id property (used in Path<T> extractor).
195
+ fn find_id_type(meta: &TypeMetadata) -> String {
196
+ // Explicit @id annotation
197
+ for (_name, prop) in &meta.properties {
198
+ if prop
199
+ .jsdoc
200
+ .as_ref()
201
+ .map(|j| j.contains_key("id"))
202
+ .unwrap_or(false)
203
+ {
204
+ return id_rust_type(&prop.type_str);
205
+ }
206
+ }
207
+ // Fall back to property named "id"
208
+ if let Some(prop) = meta.properties.get("id") {
209
+ return id_rust_type(&prop.type_str);
210
+ }
211
+ "String".to_string()
212
+ }
213
+
214
+ /// Map TS type to Rust type for ID fields in handler extractors.
215
+ fn id_rust_type(type_str: &str) -> String {
216
+ match type_str {
217
+ "string" => "String".to_string(),
218
+ "number" => "i64".to_string(),
219
+ _ => "String".to_string(),
220
+ }
221
+ }
222
+
223
+ // ─────────────────────────── File Generation ─────────────────────────────────
224
+
225
+ /// Generate handler file content for a single entity.
226
+ fn generate_entity_handler_file(entity: &EntityInfo, actions: &[HandlerAction]) -> String {
227
+ let mut output = String::new();
228
+
229
+ // Deduplicate actions (e.g., PUT + PATCH both produce "update")
230
+ let mut seen = BTreeSet::new();
231
+ let unique_actions: Vec<&HandlerAction> = actions
232
+ .iter()
233
+ .filter(|a| seen.insert(a.action.clone()))
234
+ .collect();
235
+
236
+ output.push_str("// AUTO-GENERATED by @typokit/transform-native\n");
237
+ output.push_str("// This file will NOT be overwritten — edit freely.\n\n");
238
+
239
+ // Determine needed imports
240
+ let needs_path = unique_actions.iter().any(|a| a.has_path_param);
241
+ let needs_query = unique_actions.iter().any(|a| a.action == "list");
242
+ let needs_json = unique_actions
243
+ .iter()
244
+ .any(|a| matches!(a.action.as_str(), "create" | "update" | "list" | "get_by_id"));
245
+ let needs_status_code = unique_actions
246
+ .iter()
247
+ .any(|a| matches!(a.action.as_str(), "create" | "delete"));
248
+
249
+ // Axum imports
250
+ output.push_str("use axum::extract::State;\n");
251
+ if needs_path {
252
+ output.push_str("use axum::extract::Path;\n");
253
+ }
254
+ if needs_query {
255
+ output.push_str("use axum::extract::Query;\n");
256
+ }
257
+ if needs_status_code {
258
+ output.push_str("use axum::http::StatusCode;\n");
259
+ }
260
+ if needs_json {
261
+ output.push_str("use axum::Json;\n");
262
+ }
263
+
264
+ // Crate imports
265
+ output.push_str("use crate::app::AppState;\n");
266
+ if entity.is_table {
267
+ output.push_str("use crate::db::repository;\n");
268
+ }
269
+ output.push_str("use crate::error::AppError;\n");
270
+ if needs_json {
271
+ output.push_str("use crate::models;\n");
272
+ }
273
+
274
+ // ListQuery struct for list endpoints
275
+ if needs_query {
276
+ output.push_str("\n/// Pagination query parameters for list endpoints.\n");
277
+ output.push_str("#[derive(Debug, serde::Deserialize)]\n");
278
+ output.push_str("pub struct ListQuery {\n");
279
+ output.push_str(" pub page: Option<u32>,\n");
280
+ output.push_str(" pub page_size: Option<u32>,\n");
281
+ output.push_str("}\n");
282
+ }
283
+
284
+ // Handler functions
285
+ for action in &unique_actions {
286
+ output.push('\n');
287
+ match action.action.as_str() {
288
+ "list" => output.push_str(&generate_list_handler(entity)),
289
+ "create" => output.push_str(&generate_create_handler(entity)),
290
+ "get_by_id" => output.push_str(&generate_get_by_id_handler(entity)),
291
+ "update" => output.push_str(&generate_update_handler(entity)),
292
+ "delete" => output.push_str(&generate_delete_handler(entity)),
293
+ other => output.push_str(&generate_fallback_handler(other)),
294
+ }
295
+ }
296
+
297
+ output
298
+ }
299
+
300
+ fn generate_list_handler(entity: &EntityInfo) -> String {
301
+ let mut s = String::new();
302
+ s.push_str(&format!(
303
+ "/// List all {}s with pagination.\n",
304
+ entity.snake_name
305
+ ));
306
+ s.push_str("pub async fn list(\n");
307
+ s.push_str(" State(state): State<AppState>,\n");
308
+ s.push_str(" Query(query): Query<ListQuery>,\n");
309
+ s.push_str(&format!(
310
+ ") -> Result<Json<Vec<models::{}>>, AppError> {{\n",
311
+ entity.name
312
+ ));
313
+ s.push_str(" let page = query.page.unwrap_or(1);\n");
314
+ s.push_str(" let page_size = query.page_size.unwrap_or(20);\n");
315
+
316
+ if entity.is_table {
317
+ s.push_str(&format!(
318
+ " let results = repository::find_all_{}(&state.pool, page, page_size).await?;\n",
319
+ entity.snake_name
320
+ ));
321
+ s.push_str(" Ok(Json(results))\n");
322
+ } else {
323
+ s.push_str(" // TODO: Implement list logic\n");
324
+ s.push_str(" let _ = (state, page, page_size);\n");
325
+ s.push_str(" Ok(Json(vec![]))\n");
326
+ }
327
+
328
+ s.push_str("}\n");
329
+ s
330
+ }
331
+
332
+ fn generate_create_handler(entity: &EntityInfo) -> String {
333
+ let mut s = String::new();
334
+ s.push_str(&format!("/// Create a new {}.\n", entity.snake_name));
335
+ s.push_str("pub async fn create(\n");
336
+ s.push_str(" State(state): State<AppState>,\n");
337
+ s.push_str(&format!(
338
+ " Json(input): Json<models::{}>,\n",
339
+ entity.input_struct
340
+ ));
341
+ s.push_str(&format!(
342
+ ") -> Result<(StatusCode, Json<models::{}>), AppError> {{\n",
343
+ entity.name
344
+ ));
345
+
346
+ if entity.is_table {
347
+ s.push_str(&format!(
348
+ " let created = repository::create_{}(&state.pool, &input).await?;\n",
349
+ entity.snake_name
350
+ ));
351
+ s.push_str(" Ok((StatusCode::CREATED, Json(created)))\n");
352
+ } else {
353
+ s.push_str(" // TODO: Implement create logic\n");
354
+ s.push_str(" let _ = (state, input);\n");
355
+ s.push_str(" todo!()\n");
356
+ }
357
+
358
+ s.push_str("}\n");
359
+ s
360
+ }
361
+
362
+ fn generate_get_by_id_handler(entity: &EntityInfo) -> String {
363
+ let mut s = String::new();
364
+ s.push_str(&format!("/// Get a {} by ID.\n", entity.snake_name));
365
+ s.push_str("pub async fn get_by_id(\n");
366
+ s.push_str(" State(state): State<AppState>,\n");
367
+ s.push_str(&format!(
368
+ " Path(id): Path<{}>,\n",
369
+ entity.id_type
370
+ ));
371
+ s.push_str(&format!(
372
+ ") -> Result<Json<models::{}>, AppError> {{\n",
373
+ entity.name
374
+ ));
375
+
376
+ if entity.is_table {
377
+ s.push_str(&format!(
378
+ " match repository::find_{}_by_id(&state.pool, &id).await? {{\n",
379
+ entity.snake_name
380
+ ));
381
+ s.push_str(" Some(entity) => Ok(Json(entity)),\n");
382
+ s.push_str(&format!(
383
+ " None => Err(AppError::NotFound(\"{} not found\".to_string())),\n",
384
+ entity.name
385
+ ));
386
+ s.push_str(" }\n");
387
+ } else {
388
+ s.push_str(" // TODO: Implement get_by_id logic\n");
389
+ s.push_str(" let _ = (state, id);\n");
390
+ s.push_str(" todo!()\n");
391
+ }
392
+
393
+ s.push_str("}\n");
394
+ s
395
+ }
396
+
397
+ fn generate_update_handler(entity: &EntityInfo) -> String {
398
+ let mut s = String::new();
399
+ s.push_str(&format!("/// Update a {} by ID.\n", entity.snake_name));
400
+ s.push_str("pub async fn update(\n");
401
+ s.push_str(" State(state): State<AppState>,\n");
402
+ s.push_str(&format!(
403
+ " Path(id): Path<{}>,\n",
404
+ entity.id_type
405
+ ));
406
+ s.push_str(&format!(
407
+ " Json(input): Json<models::{}>,\n",
408
+ entity.input_struct
409
+ ));
410
+ s.push_str(&format!(
411
+ ") -> Result<Json<models::{}>, AppError> {{\n",
412
+ entity.name
413
+ ));
414
+
415
+ if entity.is_table {
416
+ s.push_str(&format!(
417
+ " match repository::update_{}(&state.pool, &id, &input).await? {{\n",
418
+ entity.snake_name
419
+ ));
420
+ s.push_str(" Some(entity) => Ok(Json(entity)),\n");
421
+ s.push_str(&format!(
422
+ " None => Err(AppError::NotFound(\"{} not found\".to_string())),\n",
423
+ entity.name
424
+ ));
425
+ s.push_str(" }\n");
426
+ } else {
427
+ s.push_str(" // TODO: Implement update logic\n");
428
+ s.push_str(" let _ = (state, id, input);\n");
429
+ s.push_str(" todo!()\n");
430
+ }
431
+
432
+ s.push_str("}\n");
433
+ s
434
+ }
435
+
436
+ fn generate_delete_handler(entity: &EntityInfo) -> String {
437
+ let mut s = String::new();
438
+ s.push_str(&format!("/// Delete a {} by ID.\n", entity.snake_name));
439
+ s.push_str("pub async fn delete(\n");
440
+ s.push_str(" State(state): State<AppState>,\n");
441
+ s.push_str(&format!(
442
+ " Path(id): Path<{}>,\n",
443
+ entity.id_type
444
+ ));
445
+ s.push_str(") -> Result<StatusCode, AppError> {\n");
446
+
447
+ if entity.is_table {
448
+ s.push_str(&format!(
449
+ " match repository::delete_{}(&state.pool, &id).await? {{\n",
450
+ entity.snake_name
451
+ ));
452
+ s.push_str(" Some(_) => Ok(StatusCode::NO_CONTENT),\n");
453
+ s.push_str(&format!(
454
+ " None => Err(AppError::NotFound(\"{} not found\".to_string())),\n",
455
+ entity.name
456
+ ));
457
+ s.push_str(" }\n");
458
+ } else {
459
+ s.push_str(" // TODO: Implement delete logic\n");
460
+ s.push_str(" let _ = (state, id);\n");
461
+ s.push_str(" todo!()\n");
462
+ }
463
+
464
+ s.push_str("}\n");
465
+ s
466
+ }
467
+
468
+ fn generate_fallback_handler(action: &str) -> String {
469
+ let mut s = String::new();
470
+ s.push_str(&format!("/// {} handler stub.\n", action));
471
+ s.push_str(&format!("pub async fn {}(\n", action));
472
+ s.push_str(" State(_state): State<AppState>,\n");
473
+ s.push_str(") -> Result<StatusCode, AppError> {\n");
474
+ s.push_str(&format!(" // TODO: Implement {} logic\n", action));
475
+ s.push_str(" todo!()\n");
476
+ s.push_str("}\n");
477
+ s
478
+ }
479
+
480
+ /// Generate the `src/handlers/mod.rs` with pub mod declarations.
481
+ fn generate_handlers_mod(module_names: &[String]) -> GeneratedOutput {
482
+ let mut output = String::new();
483
+ output.push_str("// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n\n");
484
+
485
+ for name in module_names {
486
+ output.push_str(&format!("pub mod {};\n", name));
487
+ }
488
+
489
+ GeneratedOutput {
490
+ path: "src/handlers/mod.rs".to_string(),
491
+ content: output,
492
+ overwrite: true,
493
+ }
494
+ }
495
+
496
+ /// Convert a camelCase or PascalCase string to snake_case.
497
+ fn to_snake_case(s: &str) -> String {
498
+ let mut result = String::new();
499
+ for (i, c) in s.chars().enumerate() {
500
+ if c.is_uppercase() {
501
+ if i > 0 {
502
+ result.push('_');
503
+ }
504
+ result.push(c.to_lowercase().next().unwrap());
505
+ } else {
506
+ result.push(c);
507
+ }
508
+ }
509
+ result
510
+ }
511
+
512
+ #[cfg(test)]
513
+ mod tests {
514
+ use super::*;
515
+ use typokit_transform_native::route_compiler::{PathSegment, RouteEntry, RouteTypeInfo};
516
+ use typokit_transform_native::type_extractor::PropertyMetadata;
517
+
518
+ fn make_route(method: &str, path: &str, handler_ref: &str) -> RouteEntry {
519
+ let segments = path
520
+ .split('/')
521
+ .filter(|s| !s.is_empty())
522
+ .map(|s| {
523
+ if let Some(param) = s.strip_prefix(':') {
524
+ PathSegment::Param(param.to_string())
525
+ } else {
526
+ PathSegment::Static(s.to_string())
527
+ }
528
+ })
529
+ .collect();
530
+
531
+ RouteEntry {
532
+ method: method.to_string(),
533
+ path: path.to_string(),
534
+ segments,
535
+ handler_ref: handler_ref.to_string(),
536
+ params_type: RouteTypeInfo::Void,
537
+ query_type: RouteTypeInfo::Void,
538
+ body_type: RouteTypeInfo::Void,
539
+ response_type: RouteTypeInfo::Void,
540
+ }
541
+ }
542
+
543
+ fn make_table_entity(name: &str) -> (String, TypeMetadata) {
544
+ let mut jsdoc = HashMap::new();
545
+ jsdoc.insert("table".to_string(), String::new());
546
+
547
+ let mut properties = HashMap::new();
548
+ let mut id_jsdoc = HashMap::new();
549
+ id_jsdoc.insert("id".to_string(), String::new());
550
+ id_jsdoc.insert("generated".to_string(), "uuid".to_string());
551
+ properties.insert(
552
+ "id".to_string(),
553
+ PropertyMetadata {
554
+ type_str: "string".to_string(),
555
+ optional: false,
556
+ jsdoc: Some(id_jsdoc),
557
+ },
558
+ );
559
+ properties.insert(
560
+ "name".to_string(),
561
+ PropertyMetadata {
562
+ type_str: "string".to_string(),
563
+ optional: false,
564
+ jsdoc: None,
565
+ },
566
+ );
567
+
568
+ (
569
+ name.to_string(),
570
+ TypeMetadata {
571
+ name: name.to_string(),
572
+ properties,
573
+ jsdoc: Some(jsdoc),
574
+ },
575
+ )
576
+ }
577
+
578
+ #[test]
579
+ fn test_generate_handlers_basic_crud() {
580
+ let mut type_map = HashMap::new();
581
+ let (name, meta) = make_table_entity("User");
582
+ type_map.insert(name, meta);
583
+
584
+ let routes = vec![
585
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
586
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
587
+ make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id"),
588
+ make_route("PUT", "/users/:id", "UsersRoutes#PUT /users/:id"),
589
+ make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id"),
590
+ ];
591
+
592
+ let outputs = generate_handlers(&type_map, &routes);
593
+
594
+ assert_eq!(outputs.len(), 2); // users.rs + mod.rs
595
+
596
+ let handler = outputs
597
+ .iter()
598
+ .find(|o| o.path == "src/handlers/users.rs")
599
+ .unwrap();
600
+ assert!(!handler.overwrite);
601
+ assert!(handler.content.contains("pub async fn list("));
602
+ assert!(handler.content.contains("pub async fn create("));
603
+ assert!(handler.content.contains("pub async fn get_by_id("));
604
+ assert!(handler.content.contains("pub async fn update("));
605
+ assert!(handler.content.contains("pub async fn delete("));
606
+
607
+ let mod_file = outputs
608
+ .iter()
609
+ .find(|o| o.path == "src/handlers/mod.rs")
610
+ .unwrap();
611
+ assert!(mod_file.overwrite);
612
+ assert!(mod_file.content.contains("pub mod users;"));
613
+ }
614
+
615
+ #[test]
616
+ fn test_handler_calls_repository() {
617
+ let mut type_map = HashMap::new();
618
+ let (name, meta) = make_table_entity("User");
619
+ type_map.insert(name, meta);
620
+
621
+ let routes = vec![
622
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
623
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
624
+ make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id"),
625
+ make_route("PUT", "/users/:id", "UsersRoutes#PUT /users/:id"),
626
+ make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id"),
627
+ ];
628
+
629
+ let outputs = generate_handlers(&type_map, &routes);
630
+ let handler = outputs
631
+ .iter()
632
+ .find(|o| o.path == "src/handlers/users.rs")
633
+ .unwrap();
634
+
635
+ assert!(handler.content.contains("repository::find_all_user"));
636
+ assert!(handler.content.contains("repository::create_user"));
637
+ assert!(handler.content.contains("repository::find_user_by_id"));
638
+ assert!(handler.content.contains("repository::update_user"));
639
+ assert!(handler.content.contains("repository::delete_user"));
640
+ }
641
+
642
+ #[test]
643
+ fn test_list_handler_uses_query_extractor() {
644
+ let mut type_map = HashMap::new();
645
+ let (name, meta) = make_table_entity("Todo");
646
+ type_map.insert(name, meta);
647
+
648
+ let routes = vec![make_route("GET", "/todos", "TodosRoutes#GET /todos")];
649
+
650
+ let outputs = generate_handlers(&type_map, &routes);
651
+ let handler = outputs
652
+ .iter()
653
+ .find(|o| o.path == "src/handlers/todos.rs")
654
+ .unwrap();
655
+
656
+ assert!(handler.content.contains("Query(query): Query<ListQuery>"));
657
+ assert!(handler.content.contains("pub struct ListQuery"));
658
+ assert!(handler.content.contains("page: Option<u32>"));
659
+ assert!(handler.content.contains("page_size: Option<u32>"));
660
+ }
661
+
662
+ #[test]
663
+ fn test_create_handler_returns_status_created() {
664
+ let mut type_map = HashMap::new();
665
+ let (name, meta) = make_table_entity("User");
666
+ type_map.insert(name, meta);
667
+
668
+ let routes = vec![make_route("POST", "/users", "UsersRoutes#POST /users")];
669
+
670
+ let outputs = generate_handlers(&type_map, &routes);
671
+ let handler = outputs
672
+ .iter()
673
+ .find(|o| o.path == "src/handlers/users.rs")
674
+ .unwrap();
675
+
676
+ assert!(handler.content.contains("StatusCode::CREATED"));
677
+ assert!(handler.content.contains("Json<models::UserWithoutId>"));
678
+ }
679
+
680
+ #[test]
681
+ fn test_get_by_id_returns_not_found() {
682
+ let mut type_map = HashMap::new();
683
+ let (name, meta) = make_table_entity("User");
684
+ type_map.insert(name, meta);
685
+
686
+ let routes = vec![make_route(
687
+ "GET",
688
+ "/users/:id",
689
+ "UsersRoutes#GET /users/:id",
690
+ )];
691
+
692
+ let outputs = generate_handlers(&type_map, &routes);
693
+ let handler = outputs
694
+ .iter()
695
+ .find(|o| o.path == "src/handlers/users.rs")
696
+ .unwrap();
697
+
698
+ assert!(handler.content.contains("Path(id): Path<String>"));
699
+ assert!(handler.content.contains("AppError::NotFound"));
700
+ assert!(handler.content.contains("User not found"));
701
+ }
702
+
703
+ #[test]
704
+ fn test_update_handler_returns_not_found() {
705
+ let mut type_map = HashMap::new();
706
+ let (name, meta) = make_table_entity("User");
707
+ type_map.insert(name, meta);
708
+
709
+ let routes = vec![make_route(
710
+ "PUT",
711
+ "/users/:id",
712
+ "UsersRoutes#PUT /users/:id",
713
+ )];
714
+
715
+ let outputs = generate_handlers(&type_map, &routes);
716
+ let handler = outputs
717
+ .iter()
718
+ .find(|o| o.path == "src/handlers/users.rs")
719
+ .unwrap();
720
+
721
+ assert!(handler.content.contains("repository::update_user"));
722
+ assert!(handler.content.contains("AppError::NotFound"));
723
+ assert!(handler
724
+ .content
725
+ .contains("Json(input): Json<models::UserWithoutId>"));
726
+ }
727
+
728
+ #[test]
729
+ fn test_delete_handler_returns_no_content() {
730
+ let mut type_map = HashMap::new();
731
+ let (name, meta) = make_table_entity("User");
732
+ type_map.insert(name, meta);
733
+
734
+ let routes = vec![make_route(
735
+ "DELETE",
736
+ "/users/:id",
737
+ "UsersRoutes#DELETE /users/:id",
738
+ )];
739
+
740
+ let outputs = generate_handlers(&type_map, &routes);
741
+ let handler = outputs
742
+ .iter()
743
+ .find(|o| o.path == "src/handlers/users.rs")
744
+ .unwrap();
745
+
746
+ assert!(handler.content.contains("StatusCode::NO_CONTENT"));
747
+ assert!(handler.content.contains("repository::delete_user"));
748
+ }
749
+
750
+ #[test]
751
+ fn test_all_handlers_use_state_extractor() {
752
+ let mut type_map = HashMap::new();
753
+ let (name, meta) = make_table_entity("User");
754
+ type_map.insert(name, meta);
755
+
756
+ let routes = vec![
757
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
758
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
759
+ make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id"),
760
+ make_route("PUT", "/users/:id", "UsersRoutes#PUT /users/:id"),
761
+ make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id"),
762
+ ];
763
+
764
+ let outputs = generate_handlers(&type_map, &routes);
765
+ let handler = outputs
766
+ .iter()
767
+ .find(|o| o.path == "src/handlers/users.rs")
768
+ .unwrap();
769
+
770
+ // Count occurrences of State(state): State<AppState>
771
+ let state_count = handler
772
+ .content
773
+ .matches("State(state): State<AppState>")
774
+ .count();
775
+ assert_eq!(state_count, 5, "All 5 handlers should use State extractor");
776
+ }
777
+
778
+ #[test]
779
+ fn test_multiple_entities() {
780
+ let mut type_map = HashMap::new();
781
+ let (name, meta) = make_table_entity("User");
782
+ type_map.insert(name, meta);
783
+ let (name, meta) = make_table_entity("Todo");
784
+ type_map.insert(name, meta);
785
+
786
+ let routes = vec![
787
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
788
+ make_route("GET", "/todos", "TodosRoutes#GET /todos"),
789
+ make_route("POST", "/todos", "TodosRoutes#POST /todos"),
790
+ ];
791
+
792
+ let outputs = generate_handlers(&type_map, &routes);
793
+
794
+ assert!(outputs.iter().any(|o| o.path == "src/handlers/users.rs"));
795
+ assert!(outputs.iter().any(|o| o.path == "src/handlers/todos.rs"));
796
+
797
+ let mod_file = outputs
798
+ .iter()
799
+ .find(|o| o.path == "src/handlers/mod.rs")
800
+ .unwrap();
801
+ assert!(mod_file.content.contains("pub mod todos;"));
802
+ assert!(mod_file.content.contains("pub mod users;"));
803
+ }
804
+
805
+ #[test]
806
+ fn test_overwrite_flags() {
807
+ let mut type_map = HashMap::new();
808
+ let (name, meta) = make_table_entity("User");
809
+ type_map.insert(name, meta);
810
+
811
+ let routes = vec![make_route("GET", "/users", "UsersRoutes#GET /users")];
812
+
813
+ let outputs = generate_handlers(&type_map, &routes);
814
+
815
+ let handler = outputs
816
+ .iter()
817
+ .find(|o| o.path == "src/handlers/users.rs")
818
+ .unwrap();
819
+ assert!(
820
+ !handler.overwrite,
821
+ "Entity handler files should NOT be overwritten"
822
+ );
823
+
824
+ let mod_file = outputs
825
+ .iter()
826
+ .find(|o| o.path == "src/handlers/mod.rs")
827
+ .unwrap();
828
+ assert!(
829
+ mod_file.overwrite,
830
+ "handlers/mod.rs should be overwritten"
831
+ );
832
+ }
833
+
834
+ #[test]
835
+ fn test_output_is_deterministic() {
836
+ let mut type_map = HashMap::new();
837
+ let (name, meta) = make_table_entity("User");
838
+ type_map.insert(name, meta);
839
+
840
+ let routes = vec![
841
+ make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id"),
842
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
843
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
844
+ ];
845
+
846
+ let output1 = generate_handlers(&type_map, &routes);
847
+ let output2 = generate_handlers(&type_map, &routes);
848
+
849
+ assert_eq!(output1.len(), output2.len());
850
+ for (a, b) in output1.iter().zip(output2.iter()) {
851
+ assert_eq!(a.path, b.path);
852
+ assert_eq!(a.content, b.content);
853
+ }
854
+ }
855
+
856
+ #[test]
857
+ fn test_non_table_entity_no_repository() {
858
+ let type_map = HashMap::new();
859
+
860
+ let routes = vec![make_route("GET", "/health", "HealthRoutes#GET /health")];
861
+
862
+ let outputs = generate_handlers(&type_map, &routes);
863
+ let handler = outputs
864
+ .iter()
865
+ .find(|o| o.path == "src/handlers/health.rs")
866
+ .unwrap();
867
+
868
+ assert!(!handler.content.contains("repository::"));
869
+ assert!(handler.content.contains("TODO"));
870
+ }
871
+
872
+ #[test]
873
+ fn test_resolve_entity_name_singular() {
874
+ let mut type_map = HashMap::new();
875
+ let (name, meta) = make_table_entity("User");
876
+ type_map.insert(name, meta);
877
+
878
+ assert_eq!(resolve_entity_name("Users", &type_map), "User");
879
+ }
880
+
881
+ #[test]
882
+ fn test_resolve_entity_name_exact() {
883
+ let mut type_map = HashMap::new();
884
+ let (name, meta) = make_table_entity("Users");
885
+ type_map.insert(name, meta);
886
+
887
+ assert_eq!(resolve_entity_name("Users", &type_map), "Users");
888
+ }
889
+
890
+ #[test]
891
+ fn test_resolve_entity_name_ies_to_y() {
892
+ let mut type_map = HashMap::new();
893
+ let (name, meta) = make_table_entity("Category");
894
+ type_map.insert(name, meta);
895
+
896
+ assert_eq!(resolve_entity_name("Categories", &type_map), "Category");
897
+ }
898
+
899
+ #[test]
900
+ fn test_resolve_entity_name_fallback() {
901
+ let type_map = HashMap::new();
902
+
903
+ // Not in type_map — falls back to best-effort singular
904
+ assert_eq!(resolve_entity_name("Widgets", &type_map), "Widget");
905
+ }
906
+
907
+ #[test]
908
+ fn test_handler_auto_generated_header() {
909
+ let type_map = HashMap::new();
910
+ let routes = vec![make_route("GET", "/users", "UsersRoutes#GET /users")];
911
+
912
+ let outputs = generate_handlers(&type_map, &routes);
913
+ let handler = outputs
914
+ .iter()
915
+ .find(|o| o.path == "src/handlers/users.rs")
916
+ .unwrap();
917
+ assert!(handler.content.contains("AUTO-GENERATED"));
918
+ assert!(handler.content.contains("will NOT be overwritten"));
919
+ }
920
+
921
+ #[test]
922
+ fn test_mod_auto_generated_header() {
923
+ let type_map = HashMap::new();
924
+ let routes = vec![make_route("GET", "/users", "UsersRoutes#GET /users")];
925
+
926
+ let outputs = generate_handlers(&type_map, &routes);
927
+ let mod_file = outputs
928
+ .iter()
929
+ .find(|o| o.path == "src/handlers/mod.rs")
930
+ .unwrap();
931
+ assert!(mod_file.content.contains("AUTO-GENERATED"));
932
+ }
933
+
934
+ #[test]
935
+ fn test_derive_module_info() {
936
+ let (module, prefix) = derive_module_info("UsersRoutes#GET /users");
937
+ assert_eq!(module, "users");
938
+ assert_eq!(prefix, "Users");
939
+
940
+ let (module, prefix) = derive_module_info("TodosRoutes#POST /todos");
941
+ assert_eq!(module, "todos");
942
+ assert_eq!(prefix, "Todos");
943
+ }
944
+
945
+ #[test]
946
+ fn test_handler_imports_complete() {
947
+ let mut type_map = HashMap::new();
948
+ let (name, meta) = make_table_entity("User");
949
+ type_map.insert(name, meta);
950
+
951
+ let routes = vec![
952
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
953
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
954
+ make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id"),
955
+ ];
956
+
957
+ let outputs = generate_handlers(&type_map, &routes);
958
+ let handler = outputs
959
+ .iter()
960
+ .find(|o| o.path == "src/handlers/users.rs")
961
+ .unwrap();
962
+
963
+ assert!(handler.content.contains("use axum::extract::State;"));
964
+ assert!(handler.content.contains("use axum::extract::Path;"));
965
+ assert!(handler.content.contains("use axum::extract::Query;"));
966
+ assert!(handler.content.contains("use axum::Json;"));
967
+ assert!(handler.content.contains("use axum::http::StatusCode;"));
968
+ assert!(handler.content.contains("use crate::app::AppState;"));
969
+ assert!(handler.content.contains("use crate::db::repository;"));
970
+ assert!(handler.content.contains("use crate::error::AppError;"));
971
+ assert!(handler.content.contains("use crate::models;"));
972
+ }
973
+
974
+ #[test]
975
+ fn test_empty_routes_only_mod() {
976
+ let type_map = HashMap::new();
977
+ let routes: Vec<RouteEntry> = vec![];
978
+
979
+ let outputs = generate_handlers(&type_map, &routes);
980
+
981
+ assert_eq!(outputs.len(), 1);
982
+ assert_eq!(outputs[0].path, "src/handlers/mod.rs");
983
+ }
984
+
985
+ #[test]
986
+ fn test_id_type_number() {
987
+ let mut type_map = HashMap::new();
988
+ let mut jsdoc = HashMap::new();
989
+ jsdoc.insert("table".to_string(), String::new());
990
+
991
+ let mut properties = HashMap::new();
992
+ let mut id_jsdoc = HashMap::new();
993
+ id_jsdoc.insert("id".to_string(), String::new());
994
+ properties.insert(
995
+ "id".to_string(),
996
+ PropertyMetadata {
997
+ type_str: "number".to_string(),
998
+ optional: false,
999
+ jsdoc: Some(id_jsdoc),
1000
+ },
1001
+ );
1002
+
1003
+ type_map.insert(
1004
+ "User".to_string(),
1005
+ TypeMetadata {
1006
+ name: "User".to_string(),
1007
+ properties,
1008
+ jsdoc: Some(jsdoc),
1009
+ },
1010
+ );
1011
+
1012
+ let routes = vec![make_route(
1013
+ "GET",
1014
+ "/users/:id",
1015
+ "UsersRoutes#GET /users/:id",
1016
+ )];
1017
+
1018
+ let outputs = generate_handlers(&type_map, &routes);
1019
+ let handler = outputs
1020
+ .iter()
1021
+ .find(|o| o.path == "src/handlers/users.rs")
1022
+ .unwrap();
1023
+
1024
+ assert!(handler.content.contains("Path(id): Path<i64>"));
1025
+ }
1026
+
1027
+ #[test]
1028
+ fn test_patch_maps_to_update() {
1029
+ let mut type_map = HashMap::new();
1030
+ let (name, meta) = make_table_entity("User");
1031
+ type_map.insert(name, meta);
1032
+
1033
+ let routes = vec![make_route(
1034
+ "PATCH",
1035
+ "/users/:id",
1036
+ "UsersRoutes#PATCH /users/:id",
1037
+ )];
1038
+
1039
+ let outputs = generate_handlers(&type_map, &routes);
1040
+ let handler = outputs
1041
+ .iter()
1042
+ .find(|o| o.path == "src/handlers/users.rs")
1043
+ .unwrap();
1044
+
1045
+ assert!(handler.content.contains("pub async fn update("));
1046
+ assert!(handler.content.contains("repository::update_user"));
1047
+ }
1048
+
1049
+ #[test]
1050
+ fn test_put_and_patch_deduplicates_to_single_update() {
1051
+ let mut type_map = HashMap::new();
1052
+ let (name, meta) = make_table_entity("User");
1053
+ type_map.insert(name, meta);
1054
+
1055
+ let routes = vec![
1056
+ make_route("PUT", "/users/:id", "UsersRoutes#PUT /users/:id"),
1057
+ make_route("PATCH", "/users/:id", "UsersRoutes#PATCH /users/:id"),
1058
+ ];
1059
+
1060
+ let outputs = generate_handlers(&type_map, &routes);
1061
+ let handler = outputs
1062
+ .iter()
1063
+ .find(|o| o.path == "src/handlers/users.rs")
1064
+ .unwrap();
1065
+
1066
+ // Should only have one update handler, not two
1067
+ let update_count = handler.content.matches("pub async fn update(").count();
1068
+ assert_eq!(update_count, 1, "Should deduplicate PUT+PATCH to single update");
1069
+ }
1070
+
1071
+ #[test]
1072
+ fn test_non_table_entity_has_no_repository_import() {
1073
+ let type_map = HashMap::new();
1074
+ let routes = vec![make_route("GET", "/health", "HealthRoutes#GET /health")];
1075
+
1076
+ let outputs = generate_handlers(&type_map, &routes);
1077
+ let handler = outputs
1078
+ .iter()
1079
+ .find(|o| o.path == "src/handlers/health.rs")
1080
+ .unwrap();
1081
+
1082
+ assert!(!handler.content.contains("use crate::db::repository;"));
1083
+ }
1084
+
1085
+ #[test]
1086
+ fn test_mod_rs_sorted_alphabetically() {
1087
+ let mut type_map = HashMap::new();
1088
+ let (name, meta) = make_table_entity("User");
1089
+ type_map.insert(name, meta);
1090
+ let (name, meta) = make_table_entity("Todo");
1091
+ type_map.insert(name, meta);
1092
+
1093
+ let routes = vec![
1094
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
1095
+ make_route("GET", "/todos", "TodosRoutes#GET /todos"),
1096
+ ];
1097
+
1098
+ let outputs = generate_handlers(&type_map, &routes);
1099
+ let mod_file = outputs
1100
+ .iter()
1101
+ .find(|o| o.path == "src/handlers/mod.rs")
1102
+ .unwrap();
1103
+
1104
+ let todos_pos = mod_file.content.find("pub mod todos;").unwrap();
1105
+ let users_pos = mod_file.content.find("pub mod users;").unwrap();
1106
+ assert!(
1107
+ todos_pos < users_pos,
1108
+ "Module names should be sorted alphabetically"
1109
+ );
1110
+ }
1111
+ }