@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,385 @@
1
+ use std::collections::BTreeMap;
2
+
3
+ use typokit_transform_native::route_compiler::RouteEntry;
4
+ use super::GeneratedOutput;
5
+
6
+ /// Generate the Axum router file from compiled route entries.
7
+ ///
8
+ /// Produces `.typokit/router.rs` containing a `pub fn create_router() -> Router<AppState>`
9
+ /// function with typed Axum handler registrations.
10
+ pub fn generate_router(routes: &[RouteEntry]) -> Vec<GeneratedOutput> {
11
+ let mut outputs = Vec::new();
12
+
13
+ let content = generate_router_file(routes);
14
+ outputs.push(GeneratedOutput {
15
+ path: ".typokit/router.rs".to_string(),
16
+ content,
17
+ overwrite: true,
18
+ });
19
+
20
+ outputs
21
+ }
22
+
23
+ /// Generate the router.rs file content.
24
+ fn generate_router_file(routes: &[RouteEntry]) -> String {
25
+ let mut output = String::new();
26
+
27
+ output.push_str("// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n\n");
28
+
29
+ // Collect unique handler module references for imports
30
+ let handler_modules = collect_handler_modules(routes);
31
+
32
+ // Imports
33
+ output.push_str("use axum::{\n");
34
+ output.push_str(" Router,\n");
35
+
36
+ // Collect needed routing methods
37
+ let methods = collect_routing_methods(routes);
38
+ if !methods.is_empty() {
39
+ output.push_str(&format!(" routing::{{{}}},\n", methods.join(", ")));
40
+ }
41
+
42
+ output.push_str("};\n");
43
+ output.push_str("use crate::app::AppState;\n");
44
+
45
+ // Import handler modules
46
+ for module in &handler_modules {
47
+ output.push_str(&format!("use crate::handlers::{};\n", module));
48
+ }
49
+
50
+ output.push_str("\n");
51
+
52
+ // Generate create_router function
53
+ output.push_str("/// Create the application router with all route registrations.\n");
54
+ output.push_str("pub fn create_router() -> Router<AppState> {\n");
55
+ output.push_str(" Router::new()\n");
56
+
57
+ // Group routes by path for chaining
58
+ let grouped = group_routes_by_path(routes);
59
+ for (path, route_methods) in &grouped {
60
+ let axum_path = to_axum_path(path);
61
+ let method_chains: Vec<String> = route_methods
62
+ .iter()
63
+ .map(|(method, handler_fn)| format!("{}({})", method, handler_fn))
64
+ .collect();
65
+
66
+ if method_chains.len() == 1 {
67
+ output.push_str(&format!(
68
+ " .route(\"{}\", {})\n",
69
+ axum_path, method_chains[0]
70
+ ));
71
+ } else {
72
+ // Multiple methods on same path: chain with .method()
73
+ let chained = method_chains.join(".");
74
+ output.push_str(&format!(
75
+ " .route(\"{}\", {chained})\n",
76
+ axum_path,
77
+ ));
78
+ }
79
+ }
80
+
81
+ output.push_str("}\n");
82
+
83
+ output
84
+ }
85
+
86
+ /// Collect unique handler module names from route entries.
87
+ ///
88
+ /// Derives entity module names from the handler_ref (e.g., "UsersRoutes#GET /users" → "users").
89
+ fn collect_handler_modules(routes: &[RouteEntry]) -> Vec<String> {
90
+ let mut modules = BTreeMap::new();
91
+ for route in routes {
92
+ let module = derive_handler_module(&route.handler_ref);
93
+ modules.entry(module).or_insert(());
94
+ }
95
+ modules.into_keys().collect()
96
+ }
97
+
98
+ /// Collect unique Axum routing method names needed (get, post, put, patch, delete).
99
+ fn collect_routing_methods(routes: &[RouteEntry]) -> Vec<String> {
100
+ let mut methods = BTreeMap::new();
101
+ for route in routes {
102
+ let method = route.method.to_lowercase();
103
+ methods.entry(method).or_insert(());
104
+ }
105
+ methods.into_keys().collect()
106
+ }
107
+
108
+ /// Group routes by path, collecting (axum_method_fn, handler_fn) pairs per path.
109
+ ///
110
+ /// Uses BTreeMap for deterministic ordering.
111
+ fn group_routes_by_path(routes: &[RouteEntry]) -> BTreeMap<String, Vec<(String, String)>> {
112
+ let mut grouped: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
113
+
114
+ // Sort routes for deterministic output
115
+ let mut sorted_routes: Vec<&RouteEntry> = routes.iter().collect();
116
+ sorted_routes.sort_by(|a, b| a.path.cmp(&b.path).then(a.method.cmp(&b.method)));
117
+
118
+ for route in sorted_routes {
119
+ let method_fn = route.method.to_lowercase();
120
+ let handler_fn = derive_handler_fn(route);
121
+ grouped
122
+ .entry(route.path.clone())
123
+ .or_default()
124
+ .push((method_fn, handler_fn));
125
+ }
126
+
127
+ grouped
128
+ }
129
+
130
+ /// Derive the handler module name from a handler_ref.
131
+ ///
132
+ /// Handler refs follow the pattern "EntityRoutes#METHOD /path" — we extract the entity
133
+ /// name, strip "Routes" suffix, and convert to snake_case for the module name.
134
+ fn derive_handler_module(handler_ref: &str) -> String {
135
+ // Split on '#' to get the contract interface name
136
+ let contract_name = handler_ref
137
+ .split('#')
138
+ .next()
139
+ .unwrap_or(handler_ref);
140
+
141
+ // Strip "Routes" suffix if present
142
+ let entity = contract_name
143
+ .strip_suffix("Routes")
144
+ .or_else(|| contract_name.strip_suffix("Route"))
145
+ .unwrap_or(contract_name);
146
+
147
+ to_snake_case(entity)
148
+ }
149
+
150
+ /// Derive the fully qualified handler function reference from a route entry.
151
+ ///
152
+ /// Maps HTTP method + path to handler function following the pattern:
153
+ /// handlers::{entity}::{action}
154
+ fn derive_handler_fn(route: &RouteEntry) -> String {
155
+ let module = derive_handler_module(&route.handler_ref);
156
+ let action = derive_action_name(route);
157
+ format!("{}::{}", module, action)
158
+ }
159
+
160
+ /// Derive the handler action name from the HTTP method and path.
161
+ ///
162
+ /// Follows REST conventions:
163
+ /// - GET /entities → list
164
+ /// - POST /entities → create
165
+ /// - GET /entities/:id → get_by_id
166
+ /// - PUT /entities/:id → update
167
+ /// - PATCH /entities/:id → update
168
+ /// - DELETE /entities/:id → delete
169
+ fn derive_action_name(route: &RouteEntry) -> String {
170
+ let has_param = route.segments.iter().any(|s| {
171
+ matches!(s, typokit_transform_native::route_compiler::PathSegment::Param(_))
172
+ });
173
+
174
+ match (route.method.as_str(), has_param) {
175
+ ("GET", false) => "list".to_string(),
176
+ ("GET", true) => "get_by_id".to_string(),
177
+ ("POST", false) => "create".to_string(),
178
+ ("PUT", true) | ("PATCH", true) => "update".to_string(),
179
+ ("DELETE", true) => "delete".to_string(),
180
+ // Fallback: use method name as action
181
+ (method, _) => to_snake_case(&method.to_lowercase()),
182
+ }
183
+ }
184
+
185
+ /// Convert Express-style path params (:id) to Axum-style (/:id).
186
+ ///
187
+ /// Axum uses the same `:param` syntax as Express, so paths are mostly
188
+ /// compatible. We just ensure proper formatting.
189
+ fn to_axum_path(path: &str) -> String {
190
+ // Axum uses /:param syntax same as Express — paths are already compatible
191
+ path.to_string()
192
+ }
193
+
194
+ /// Convert a camelCase or PascalCase string to snake_case.
195
+ fn to_snake_case(s: &str) -> String {
196
+ let mut result = String::new();
197
+ for (i, c) in s.chars().enumerate() {
198
+ if c.is_uppercase() {
199
+ if i > 0 {
200
+ result.push('_');
201
+ }
202
+ result.push(c.to_lowercase().next().unwrap());
203
+ } else {
204
+ result.push(c);
205
+ }
206
+ }
207
+ result
208
+ }
209
+
210
+ #[cfg(test)]
211
+ mod tests {
212
+ use super::*;
213
+ use typokit_transform_native::route_compiler::{PathSegment, RouteEntry, RouteTypeInfo};
214
+
215
+ fn make_route(method: &str, path: &str, handler_ref: &str) -> RouteEntry {
216
+ let segments = path
217
+ .split('/')
218
+ .filter(|s| !s.is_empty())
219
+ .map(|s| {
220
+ if let Some(param) = s.strip_prefix(':') {
221
+ PathSegment::Param(param.to_string())
222
+ } else {
223
+ PathSegment::Static(s.to_string())
224
+ }
225
+ })
226
+ .collect();
227
+
228
+ RouteEntry {
229
+ method: method.to_string(),
230
+ path: path.to_string(),
231
+ segments,
232
+ handler_ref: handler_ref.to_string(),
233
+ params_type: RouteTypeInfo::Void,
234
+ query_type: RouteTypeInfo::Void,
235
+ body_type: RouteTypeInfo::Void,
236
+ response_type: RouteTypeInfo::Void,
237
+ }
238
+ }
239
+
240
+ #[test]
241
+ fn test_generate_router_basic() {
242
+ let routes = vec![
243
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
244
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
245
+ make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id"),
246
+ ];
247
+
248
+ let outputs = generate_router(&routes);
249
+ assert_eq!(outputs.len(), 1);
250
+ assert_eq!(outputs[0].path, ".typokit/router.rs");
251
+ assert!(outputs[0].overwrite);
252
+
253
+ let content = &outputs[0].content;
254
+ assert!(content.contains("pub fn create_router() -> Router<AppState>"));
255
+ assert!(content.contains("use crate::handlers::users;"));
256
+ assert!(content.contains("users::list"));
257
+ assert!(content.contains("users::create"));
258
+ assert!(content.contains("users::get_by_id"));
259
+ }
260
+
261
+ #[test]
262
+ fn test_generate_router_multiple_entities() {
263
+ let routes = vec![
264
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
265
+ make_route("GET", "/todos", "TodosRoutes#GET /todos"),
266
+ make_route("POST", "/todos", "TodosRoutes#POST /todos"),
267
+ ];
268
+
269
+ let outputs = generate_router(&routes);
270
+ let content = &outputs[0].content;
271
+
272
+ assert!(content.contains("use crate::handlers::todos;"));
273
+ assert!(content.contains("use crate::handlers::users;"));
274
+ assert!(content.contains("todos::list"));
275
+ assert!(content.contains("todos::create"));
276
+ assert!(content.contains("users::list"));
277
+ }
278
+
279
+ #[test]
280
+ fn test_generate_router_crud_operations() {
281
+ let routes = vec![
282
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
283
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
284
+ make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id"),
285
+ make_route("PUT", "/users/:id", "UsersRoutes#PUT /users/:id"),
286
+ make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id"),
287
+ ];
288
+
289
+ let outputs = generate_router(&routes);
290
+ let content = &outputs[0].content;
291
+
292
+ assert!(content.contains("users::list"));
293
+ assert!(content.contains("users::create"));
294
+ assert!(content.contains("users::get_by_id"));
295
+ assert!(content.contains("users::update"));
296
+ assert!(content.contains("users::delete"));
297
+ }
298
+
299
+ #[test]
300
+ fn test_derive_handler_module() {
301
+ assert_eq!(derive_handler_module("UsersRoutes#GET /users"), "users");
302
+ assert_eq!(derive_handler_module("TodosRoutes#POST /todos"), "todos");
303
+ assert_eq!(
304
+ derive_handler_module("ProjectsRoute#GET /projects"),
305
+ "projects"
306
+ );
307
+ }
308
+
309
+ #[test]
310
+ fn test_derive_action_name() {
311
+ let list = make_route("GET", "/users", "UsersRoutes#GET /users");
312
+ assert_eq!(derive_action_name(&list), "list");
313
+
314
+ let create = make_route("POST", "/users", "UsersRoutes#POST /users");
315
+ assert_eq!(derive_action_name(&create), "create");
316
+
317
+ let get = make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id");
318
+ assert_eq!(derive_action_name(&get), "get_by_id");
319
+
320
+ let update = make_route("PUT", "/users/:id", "UsersRoutes#PUT /users/:id");
321
+ assert_eq!(derive_action_name(&update), "update");
322
+
323
+ let delete = make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id");
324
+ assert_eq!(derive_action_name(&delete), "delete");
325
+
326
+ let patch = make_route("PATCH", "/users/:id", "UsersRoutes#PATCH /users/:id");
327
+ assert_eq!(derive_action_name(&patch), "update");
328
+ }
329
+
330
+ #[test]
331
+ fn test_to_axum_path() {
332
+ assert_eq!(to_axum_path("/users"), "/users");
333
+ assert_eq!(to_axum_path("/users/:id"), "/users/:id");
334
+ assert_eq!(to_axum_path("/users/:id/todos"), "/users/:id/todos");
335
+ }
336
+
337
+ #[test]
338
+ fn test_collect_routing_methods() {
339
+ let routes = vec![
340
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
341
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
342
+ make_route("GET", "/users/:id", "UsersRoutes#GET /users/:id"),
343
+ make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id"),
344
+ ];
345
+
346
+ let methods = collect_routing_methods(&routes);
347
+ assert_eq!(methods, vec!["delete", "get", "post"]);
348
+ }
349
+
350
+ #[test]
351
+ fn test_same_path_multiple_methods() {
352
+ let routes = vec![
353
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
354
+ make_route("POST", "/users", "UsersRoutes#POST /users"),
355
+ ];
356
+
357
+ let outputs = generate_router(&routes);
358
+ let content = &outputs[0].content;
359
+
360
+ // Should combine methods on same path with method chaining
361
+ assert!(content.contains("get(users::list)"));
362
+ assert!(content.contains("post(users::create)"));
363
+ }
364
+
365
+ #[test]
366
+ fn test_router_output_is_deterministic() {
367
+ let routes = vec![
368
+ make_route("DELETE", "/users/:id", "UsersRoutes#DELETE /users/:id"),
369
+ make_route("GET", "/users", "UsersRoutes#GET /users"),
370
+ make_route("POST", "/todos", "TodosRoutes#POST /todos"),
371
+ ];
372
+
373
+ let output1 = generate_router(&routes);
374
+ let output2 = generate_router(&routes);
375
+
376
+ assert_eq!(output1[0].content, output2[0].content);
377
+ }
378
+
379
+ #[test]
380
+ fn test_router_uses_auto_generated_header() {
381
+ let routes = vec![make_route("GET", "/health", "HealthRoutes#GET /health")];
382
+ let outputs = generate_router(&routes);
383
+ assert!(outputs[0].content.contains("AUTO-GENERATED"));
384
+ }
385
+ }