@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.
- package/README.md +81 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +186 -0
- package/dist/index.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.linux-x64-musl.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +57 -0
- package/src/index.ts +309 -0
- package/src/lib.rs +80 -0
- package/src/rust_codegen/database.rs +898 -0
- package/src/rust_codegen/handlers.rs +1111 -0
- package/src/rust_codegen/middleware.rs +156 -0
- package/src/rust_codegen/mod.rs +91 -0
- package/src/rust_codegen/project.rs +593 -0
- package/src/rust_codegen/router.rs +385 -0
- package/src/rust_codegen/services.rs +476 -0
- package/src/rust_codegen/structs.rs +1363 -0
|
@@ -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
|
+
}
|