@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,476 @@
1
+ use std::collections::HashMap;
2
+
3
+ use typokit_transform_native::type_extractor::TypeMetadata;
4
+ use super::GeneratedOutput;
5
+
6
+ /// Generate per-entity service stub files and a services/mod.rs.
7
+ ///
8
+ /// Produces:
9
+ /// - `src/services/{entity}.rs` per @table entity with CRUD function stubs (overwrite: false)
10
+ /// - `src/services/mod.rs` with pub mod declarations (overwrite: true)
11
+ pub fn generate_services(type_map: &HashMap<String, TypeMetadata>) -> Vec<GeneratedOutput> {
12
+ let mut outputs = Vec::new();
13
+ let mut module_names: Vec<String> = Vec::new();
14
+
15
+ // Collect @table entities sorted by name for deterministic output
16
+ let mut table_entities: Vec<&TypeMetadata> = type_map
17
+ .values()
18
+ .filter(|meta| is_table_entity(meta))
19
+ .collect();
20
+ table_entities.sort_by_key(|meta| meta.name.clone());
21
+
22
+ for meta in &table_entities {
23
+ let snake_name = to_snake_case(&meta.name);
24
+ module_names.push(snake_name.clone());
25
+
26
+ let id_type = find_id_type(meta);
27
+ let input_struct = format!("{}WithoutId", meta.name);
28
+ let content = generate_service_file(&meta.name, &snake_name, &id_type, &input_struct);
29
+ outputs.push(GeneratedOutput {
30
+ path: format!("src/services/{}.rs", snake_name),
31
+ content,
32
+ overwrite: false,
33
+ });
34
+ }
35
+
36
+ module_names.sort();
37
+ outputs.push(generate_services_mod(&module_names));
38
+
39
+ outputs
40
+ }
41
+
42
+ /// Generate service file content for a single entity.
43
+ fn generate_service_file(
44
+ entity_name: &str,
45
+ snake_name: &str,
46
+ id_type: &str,
47
+ input_struct: &str,
48
+ ) -> String {
49
+ let mut s = String::new();
50
+
51
+ s.push_str("// AUTO-GENERATED by @typokit/transform-native\n");
52
+ s.push_str("// This file will NOT be overwritten — edit freely.\n\n");
53
+
54
+ s.push_str("use sqlx::PgPool;\n");
55
+ s.push_str("use crate::error::AppError;\n");
56
+ s.push_str("use crate::models;\n\n");
57
+
58
+ // list
59
+ s.push_str(&format!(
60
+ "/// List all {}s with pagination.\n",
61
+ snake_name
62
+ ));
63
+ s.push_str(&format!(
64
+ "pub async fn list_{}(\n",
65
+ snake_name
66
+ ));
67
+ s.push_str(" _pool: &PgPool,\n");
68
+ s.push_str(" _page: u32,\n");
69
+ s.push_str(" _page_size: u32,\n");
70
+ s.push_str(&format!(
71
+ ") -> Result<Vec<models::{}>, AppError> {{\n",
72
+ entity_name
73
+ ));
74
+ s.push_str(" // TODO: Implement service-layer list logic\n");
75
+ s.push_str(" todo!()\n");
76
+ s.push_str("}\n\n");
77
+
78
+ // get_by_id
79
+ s.push_str(&format!(
80
+ "/// Get a {} by ID.\n",
81
+ snake_name
82
+ ));
83
+ s.push_str(&format!(
84
+ "pub async fn get_{}_by_id(\n",
85
+ snake_name
86
+ ));
87
+ s.push_str(" _pool: &PgPool,\n");
88
+ s.push_str(&format!(
89
+ " _id: &{},\n",
90
+ id_type
91
+ ));
92
+ s.push_str(&format!(
93
+ ") -> Result<Option<models::{}>, AppError> {{\n",
94
+ entity_name
95
+ ));
96
+ s.push_str(" // TODO: Implement service-layer get_by_id logic\n");
97
+ s.push_str(" todo!()\n");
98
+ s.push_str("}\n\n");
99
+
100
+ // create
101
+ s.push_str(&format!(
102
+ "/// Create a new {}.\n",
103
+ snake_name
104
+ ));
105
+ s.push_str(&format!(
106
+ "pub async fn create_{}(\n",
107
+ snake_name
108
+ ));
109
+ s.push_str(" _pool: &PgPool,\n");
110
+ s.push_str(&format!(
111
+ " _input: &models::{},\n",
112
+ input_struct
113
+ ));
114
+ s.push_str(&format!(
115
+ ") -> Result<models::{}, AppError> {{\n",
116
+ entity_name
117
+ ));
118
+ s.push_str(" // TODO: Implement service-layer create logic\n");
119
+ s.push_str(" todo!()\n");
120
+ s.push_str("}\n\n");
121
+
122
+ // update
123
+ s.push_str(&format!(
124
+ "/// Update a {} by ID.\n",
125
+ snake_name
126
+ ));
127
+ s.push_str(&format!(
128
+ "pub async fn update_{}(\n",
129
+ snake_name
130
+ ));
131
+ s.push_str(" _pool: &PgPool,\n");
132
+ s.push_str(&format!(
133
+ " _id: &{},\n",
134
+ id_type
135
+ ));
136
+ s.push_str(&format!(
137
+ " _input: &models::{},\n",
138
+ input_struct
139
+ ));
140
+ s.push_str(&format!(
141
+ ") -> Result<Option<models::{}>, AppError> {{\n",
142
+ entity_name
143
+ ));
144
+ s.push_str(" // TODO: Implement service-layer update logic\n");
145
+ s.push_str(" todo!()\n");
146
+ s.push_str("}\n\n");
147
+
148
+ // delete
149
+ s.push_str(&format!(
150
+ "/// Delete a {} by ID.\n",
151
+ snake_name
152
+ ));
153
+ s.push_str(&format!(
154
+ "pub async fn delete_{}(\n",
155
+ snake_name
156
+ ));
157
+ s.push_str(" _pool: &PgPool,\n");
158
+ s.push_str(&format!(
159
+ " _id: &{},\n",
160
+ id_type
161
+ ));
162
+ s.push_str(&format!(
163
+ ") -> Result<Option<models::{}>, AppError> {{\n",
164
+ entity_name
165
+ ));
166
+ s.push_str(" // TODO: Implement service-layer delete logic\n");
167
+ s.push_str(" todo!()\n");
168
+ s.push_str("}\n");
169
+
170
+ s
171
+ }
172
+
173
+ /// Generate the `src/services/mod.rs` with pub mod declarations.
174
+ fn generate_services_mod(module_names: &[String]) -> GeneratedOutput {
175
+ let mut output = String::new();
176
+ output.push_str("// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n\n");
177
+
178
+ for name in module_names {
179
+ output.push_str(&format!("pub mod {};\n", name));
180
+ }
181
+
182
+ GeneratedOutput {
183
+ path: "src/services/mod.rs".to_string(),
184
+ content: output,
185
+ overwrite: true,
186
+ }
187
+ }
188
+
189
+ /// Check if a TypeMetadata has the @table JSDoc annotation.
190
+ fn is_table_entity(meta: &TypeMetadata) -> bool {
191
+ meta.jsdoc
192
+ .as_ref()
193
+ .map(|j| j.contains_key("table"))
194
+ .unwrap_or(false)
195
+ }
196
+
197
+ /// Find the Rust type for the @id property.
198
+ fn find_id_type(meta: &TypeMetadata) -> String {
199
+ for (_name, prop) in &meta.properties {
200
+ if prop
201
+ .jsdoc
202
+ .as_ref()
203
+ .map(|j| j.contains_key("id"))
204
+ .unwrap_or(false)
205
+ {
206
+ return id_rust_type(&prop.type_str);
207
+ }
208
+ }
209
+ if let Some(prop) = meta.properties.get("id") {
210
+ return id_rust_type(&prop.type_str);
211
+ }
212
+ "String".to_string()
213
+ }
214
+
215
+ /// Map TS type to Rust type for ID fields.
216
+ fn id_rust_type(type_str: &str) -> String {
217
+ match type_str {
218
+ "string" => "String".to_string(),
219
+ "number" => "i64".to_string(),
220
+ _ => "String".to_string(),
221
+ }
222
+ }
223
+
224
+ /// Convert a camelCase or PascalCase string to snake_case.
225
+ fn to_snake_case(s: &str) -> String {
226
+ let mut result = String::new();
227
+ for (i, c) in s.chars().enumerate() {
228
+ if c.is_uppercase() {
229
+ if i > 0 {
230
+ result.push('_');
231
+ }
232
+ result.push(c.to_lowercase().next().unwrap());
233
+ } else {
234
+ result.push(c);
235
+ }
236
+ }
237
+ result
238
+ }
239
+
240
+ #[cfg(test)]
241
+ mod tests {
242
+ use super::*;
243
+ use typokit_transform_native::type_extractor::PropertyMetadata;
244
+
245
+ fn make_table_entity(name: &str) -> TypeMetadata {
246
+ let mut jsdoc = HashMap::new();
247
+ jsdoc.insert("table".to_string(), "".to_string());
248
+
249
+ let mut properties = HashMap::new();
250
+ let mut id_jsdoc = HashMap::new();
251
+ id_jsdoc.insert("id".to_string(), "".to_string());
252
+ id_jsdoc.insert("generated".to_string(), "uuid".to_string());
253
+ properties.insert(
254
+ "id".to_string(),
255
+ PropertyMetadata {
256
+ type_str: "string".to_string(),
257
+ optional: false,
258
+ jsdoc: Some(id_jsdoc),
259
+ },
260
+ );
261
+ properties.insert(
262
+ "name".to_string(),
263
+ PropertyMetadata {
264
+ type_str: "string".to_string(),
265
+ optional: false,
266
+ jsdoc: None,
267
+ },
268
+ );
269
+
270
+ TypeMetadata {
271
+ name: name.to_string(),
272
+ properties,
273
+ jsdoc: Some(jsdoc),
274
+ }
275
+ }
276
+
277
+ fn make_non_table_entity(name: &str) -> TypeMetadata {
278
+ let mut properties = HashMap::new();
279
+ properties.insert(
280
+ "id".to_string(),
281
+ PropertyMetadata {
282
+ type_str: "string".to_string(),
283
+ optional: false,
284
+ jsdoc: None,
285
+ },
286
+ );
287
+
288
+ TypeMetadata {
289
+ name: name.to_string(),
290
+ properties,
291
+ jsdoc: None,
292
+ }
293
+ }
294
+
295
+ #[test]
296
+ fn test_generate_services_empty_type_map() {
297
+ let type_map = HashMap::new();
298
+ let outputs = generate_services(&type_map);
299
+ // Should only produce mod.rs
300
+ assert_eq!(outputs.len(), 1);
301
+ assert_eq!(outputs[0].path, "src/services/mod.rs");
302
+ assert!(outputs[0].overwrite);
303
+ }
304
+
305
+ #[test]
306
+ fn test_generate_services_skips_non_table_entities() {
307
+ let mut type_map = HashMap::new();
308
+ type_map.insert("Config".to_string(), make_non_table_entity("Config"));
309
+
310
+ let outputs = generate_services(&type_map);
311
+ // Only mod.rs — no service file for non-table entity
312
+ assert_eq!(outputs.len(), 1);
313
+ assert_eq!(outputs[0].path, "src/services/mod.rs");
314
+ }
315
+
316
+ #[test]
317
+ fn test_generate_services_for_table_entity() {
318
+ let mut type_map = HashMap::new();
319
+ type_map.insert("User".to_string(), make_table_entity("User"));
320
+
321
+ let outputs = generate_services(&type_map);
322
+ assert_eq!(outputs.len(), 2); // user.rs + mod.rs
323
+
324
+ let user_service = outputs.iter().find(|o| o.path == "src/services/user.rs").unwrap();
325
+ assert!(!user_service.overwrite);
326
+ assert!(user_service.content.contains("pub async fn list_user("));
327
+ assert!(user_service.content.contains("pub async fn get_user_by_id("));
328
+ assert!(user_service.content.contains("pub async fn create_user("));
329
+ assert!(user_service.content.contains("pub async fn update_user("));
330
+ assert!(user_service.content.contains("pub async fn delete_user("));
331
+ }
332
+
333
+ #[test]
334
+ fn test_service_file_imports_model_types() {
335
+ let mut type_map = HashMap::new();
336
+ type_map.insert("User".to_string(), make_table_entity("User"));
337
+
338
+ let outputs = generate_services(&type_map);
339
+ let user_service = outputs.iter().find(|o| o.path == "src/services/user.rs").unwrap();
340
+ assert!(user_service.content.contains("use crate::models;"));
341
+ assert!(user_service.content.contains("models::User"));
342
+ assert!(user_service.content.contains("models::UserWithoutId"));
343
+ }
344
+
345
+ #[test]
346
+ fn test_service_file_has_correct_signatures() {
347
+ let mut type_map = HashMap::new();
348
+ type_map.insert("User".to_string(), make_table_entity("User"));
349
+
350
+ let outputs = generate_services(&type_map);
351
+ let user_service = outputs.iter().find(|o| o.path == "src/services/user.rs").unwrap();
352
+
353
+ // list: returns Vec
354
+ assert!(user_service.content.contains("-> Result<Vec<models::User>, AppError>"));
355
+ // get_by_id: returns Option
356
+ assert!(user_service.content.contains("-> Result<Option<models::User>, AppError>"));
357
+ // create: returns entity
358
+ assert!(user_service.content.contains(") -> Result<models::User, AppError>"));
359
+ // update: returns Option
360
+ assert!(user_service.content.contains("_input: &models::UserWithoutId"));
361
+ // delete: returns Option
362
+ assert!(user_service.content.contains("pub async fn delete_user("));
363
+ }
364
+
365
+ #[test]
366
+ fn test_service_file_overwrite_false() {
367
+ let mut type_map = HashMap::new();
368
+ type_map.insert("User".to_string(), make_table_entity("User"));
369
+
370
+ let outputs = generate_services(&type_map);
371
+ let user_service = outputs.iter().find(|o| o.path == "src/services/user.rs").unwrap();
372
+ assert!(!user_service.overwrite);
373
+ assert!(user_service.content.contains("will NOT be overwritten"));
374
+ }
375
+
376
+ #[test]
377
+ fn test_services_mod_overwrite_true() {
378
+ let mut type_map = HashMap::new();
379
+ type_map.insert("User".to_string(), make_table_entity("User"));
380
+
381
+ let outputs = generate_services(&type_map);
382
+ let mod_file = outputs.iter().find(|o| o.path == "src/services/mod.rs").unwrap();
383
+ assert!(mod_file.overwrite);
384
+ assert!(mod_file.content.contains("pub mod user;"));
385
+ }
386
+
387
+ #[test]
388
+ fn test_generate_services_multiple_entities_sorted() {
389
+ let mut type_map = HashMap::new();
390
+ type_map.insert("Todo".to_string(), make_table_entity("Todo"));
391
+ type_map.insert("User".to_string(), make_table_entity("User"));
392
+ type_map.insert("Category".to_string(), make_table_entity("Category"));
393
+
394
+ let outputs = generate_services(&type_map);
395
+ // 3 entity files + mod.rs
396
+ assert_eq!(outputs.len(), 4);
397
+
398
+ let mod_file = outputs.iter().find(|o| o.path == "src/services/mod.rs").unwrap();
399
+ let lines: Vec<&str> = mod_file.content.lines().collect();
400
+ let mod_lines: Vec<&&str> = lines.iter().filter(|l| l.starts_with("pub mod")).collect();
401
+ assert_eq!(mod_lines.len(), 3);
402
+ assert_eq!(*mod_lines[0], "pub mod category;");
403
+ assert_eq!(*mod_lines[1], "pub mod todo;");
404
+ assert_eq!(*mod_lines[2], "pub mod user;");
405
+ }
406
+
407
+ #[test]
408
+ fn test_service_id_type_number() {
409
+ let mut type_map = HashMap::new();
410
+ let mut properties = HashMap::new();
411
+ let mut id_jsdoc = HashMap::new();
412
+ id_jsdoc.insert("id".to_string(), "".to_string());
413
+ properties.insert(
414
+ "id".to_string(),
415
+ PropertyMetadata {
416
+ type_str: "number".to_string(),
417
+ optional: false,
418
+ jsdoc: Some(id_jsdoc),
419
+ },
420
+ );
421
+ let mut jsdoc = HashMap::new();
422
+ jsdoc.insert("table".to_string(), "".to_string());
423
+ type_map.insert(
424
+ "Item".to_string(),
425
+ TypeMetadata {
426
+ name: "Item".to_string(),
427
+ properties,
428
+ jsdoc: Some(jsdoc),
429
+ },
430
+ );
431
+
432
+ let outputs = generate_services(&type_map);
433
+ let item_service = outputs.iter().find(|o| o.path == "src/services/item.rs").unwrap();
434
+ assert!(item_service.content.contains("_id: &i64"));
435
+ }
436
+
437
+ #[test]
438
+ fn test_service_uses_pool_and_app_error() {
439
+ let mut type_map = HashMap::new();
440
+ type_map.insert("User".to_string(), make_table_entity("User"));
441
+
442
+ let outputs = generate_services(&type_map);
443
+ let user_service = outputs.iter().find(|o| o.path == "src/services/user.rs").unwrap();
444
+ assert!(user_service.content.contains("use sqlx::PgPool;"));
445
+ assert!(user_service.content.contains("use crate::error::AppError;"));
446
+ assert!(user_service.content.contains("_pool: &PgPool"));
447
+ }
448
+
449
+ #[test]
450
+ fn test_service_pagination_params() {
451
+ let mut type_map = HashMap::new();
452
+ type_map.insert("User".to_string(), make_table_entity("User"));
453
+
454
+ let outputs = generate_services(&type_map);
455
+ let user_service = outputs.iter().find(|o| o.path == "src/services/user.rs").unwrap();
456
+ assert!(user_service.content.contains("_page: u32"));
457
+ assert!(user_service.content.contains("_page_size: u32"));
458
+ }
459
+
460
+ #[test]
461
+ fn test_deterministic_output() {
462
+ let mut type_map = HashMap::new();
463
+ type_map.insert("Todo".to_string(), make_table_entity("Todo"));
464
+ type_map.insert("User".to_string(), make_table_entity("User"));
465
+
466
+ let outputs1 = generate_services(&type_map);
467
+ let outputs2 = generate_services(&type_map);
468
+
469
+ assert_eq!(outputs1.len(), outputs2.len());
470
+ for (a, b) in outputs1.iter().zip(outputs2.iter()) {
471
+ assert_eq!(a.path, b.path);
472
+ assert_eq!(a.content, b.content);
473
+ assert_eq!(a.overwrite, b.overwrite);
474
+ }
475
+ }
476
+ }