@typokit/transform-native 0.1.4
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/dist/index.d.ts +148 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +290 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/env.d.ts +40 -0
- package/src/index.d.ts +135 -0
- package/src/index.test.ts +878 -0
- package/src/index.ts +437 -0
- package/src/lib.rs +388 -0
- package/src/openapi_generator.rs +525 -0
- package/src/output_pipeline.rs +234 -0
- package/src/parser.rs +105 -0
- package/src/route_compiler.rs +615 -0
- package/src/schema_differ.rs +393 -0
- package/src/test_stub_generator.rs +318 -0
- package/src/type_extractor.rs +370 -0
- package/src/typia_bridge.rs +179 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
use std::collections::BTreeMap;
|
|
2
|
+
use serde::{Serialize, Deserialize};
|
|
3
|
+
use swc_ecma_ast::*;
|
|
4
|
+
|
|
5
|
+
// ─── Type Representation for Route Contract Parameters ───────
|
|
6
|
+
|
|
7
|
+
/// Rich type info extracted from RouteContract type parameters
|
|
8
|
+
#[derive(Debug, Clone)]
|
|
9
|
+
pub enum RouteTypeInfo {
|
|
10
|
+
Void,
|
|
11
|
+
Primitive(String),
|
|
12
|
+
Named(String),
|
|
13
|
+
Generic(String, Vec<RouteTypeInfo>),
|
|
14
|
+
Array(Box<RouteTypeInfo>),
|
|
15
|
+
ObjectLiteral(Vec<RouteObjectProp>),
|
|
16
|
+
Union(Vec<RouteTypeInfo>),
|
|
17
|
+
StringLiteral(String),
|
|
18
|
+
NumberLiteral(f64),
|
|
19
|
+
BooleanLiteral(bool),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[derive(Debug, Clone)]
|
|
23
|
+
pub struct RouteObjectProp {
|
|
24
|
+
pub name: String,
|
|
25
|
+
pub type_info: RouteTypeInfo,
|
|
26
|
+
pub optional: bool,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Route Entry ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/// A parsed route entry from a route contract interface
|
|
32
|
+
#[derive(Debug, Clone)]
|
|
33
|
+
pub struct RouteEntry {
|
|
34
|
+
pub method: String,
|
|
35
|
+
pub path: String,
|
|
36
|
+
pub segments: Vec<PathSegment>,
|
|
37
|
+
pub handler_ref: String,
|
|
38
|
+
pub params_type: RouteTypeInfo,
|
|
39
|
+
pub query_type: RouteTypeInfo,
|
|
40
|
+
pub body_type: RouteTypeInfo,
|
|
41
|
+
pub response_type: RouteTypeInfo,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// A segment in a route path
|
|
45
|
+
#[derive(Debug, Clone)]
|
|
46
|
+
pub enum PathSegment {
|
|
47
|
+
Static(String),
|
|
48
|
+
Param(String),
|
|
49
|
+
Wildcard(String),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Compiled Route Tree ─────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/// Handler entry in the compiled route table
|
|
55
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
56
|
+
pub struct RouteHandlerEntry {
|
|
57
|
+
#[serde(rename = "ref")]
|
|
58
|
+
pub ref_str: String,
|
|
59
|
+
pub middleware: Vec<String>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// A compiled radix tree node (matches @typokit/types CompiledRoute)
|
|
63
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
64
|
+
pub struct RouteNode {
|
|
65
|
+
pub segment: String,
|
|
66
|
+
#[serde(skip_serializing_if = "Option::is_none", rename = "paramName")]
|
|
67
|
+
pub param_name: Option<String>,
|
|
68
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
69
|
+
pub children: Option<BTreeMap<String, RouteNode>>,
|
|
70
|
+
#[serde(skip_serializing_if = "Option::is_none", rename = "paramChild")]
|
|
71
|
+
pub param_child: Option<Box<RouteNode>>,
|
|
72
|
+
#[serde(skip_serializing_if = "Option::is_none", rename = "wildcardChild")]
|
|
73
|
+
pub wildcard_child: Option<Box<RouteNode>>,
|
|
74
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
75
|
+
pub handlers: Option<BTreeMap<String, RouteHandlerEntry>>,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
impl RouteNode {
|
|
79
|
+
pub fn new(segment: &str) -> Self {
|
|
80
|
+
Self {
|
|
81
|
+
segment: segment.to_string(),
|
|
82
|
+
param_name: None,
|
|
83
|
+
children: None,
|
|
84
|
+
param_child: None,
|
|
85
|
+
wildcard_child: None,
|
|
86
|
+
handlers: None,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Route Contract Extraction ───────────────────────────────
|
|
92
|
+
|
|
93
|
+
/// Extract route contracts from a parsed TypeScript module.
|
|
94
|
+
/// Looks for interfaces with string literal property keys matching "METHOD /path".
|
|
95
|
+
pub fn extract_route_contracts(module: &Module) -> Vec<RouteEntry> {
|
|
96
|
+
let mut entries = Vec::new();
|
|
97
|
+
|
|
98
|
+
for item in &module.body {
|
|
99
|
+
let iface = match item {
|
|
100
|
+
ModuleItem::Stmt(Stmt::Decl(Decl::TsInterface(iface))) => iface,
|
|
101
|
+
ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => {
|
|
102
|
+
if let Decl::TsInterface(iface) = &export.decl {
|
|
103
|
+
iface
|
|
104
|
+
} else {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
_ => continue,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let iface_name = iface.id.sym.to_string();
|
|
112
|
+
|
|
113
|
+
for member in &iface.body.body {
|
|
114
|
+
if let TsTypeElement::TsPropertySignature(prop) = member {
|
|
115
|
+
let key_str = match &*prop.key {
|
|
116
|
+
Expr::Lit(Lit::Str(s)) => s.value.to_string_lossy().to_string(),
|
|
117
|
+
_ => continue,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
let (method, path) = match parse_route_key(&key_str) {
|
|
121
|
+
Some(v) => v,
|
|
122
|
+
None => continue,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let (params_type, query_type, body_type, response_type) =
|
|
126
|
+
extract_route_contract_type_params(prop);
|
|
127
|
+
|
|
128
|
+
let segments = parse_path_segments(&path);
|
|
129
|
+
let handler_ref = format!("{}#{}", iface_name, key_str);
|
|
130
|
+
|
|
131
|
+
entries.push(RouteEntry {
|
|
132
|
+
method,
|
|
133
|
+
path,
|
|
134
|
+
segments,
|
|
135
|
+
handler_ref,
|
|
136
|
+
params_type,
|
|
137
|
+
query_type,
|
|
138
|
+
body_type,
|
|
139
|
+
response_type,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
entries
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fn parse_route_key(key: &str) -> Option<(String, String)> {
|
|
149
|
+
let parts: Vec<&str> = key.splitn(2, ' ').collect();
|
|
150
|
+
if parts.len() != 2 {
|
|
151
|
+
return None;
|
|
152
|
+
}
|
|
153
|
+
let method = parts[0].to_uppercase();
|
|
154
|
+
let path = parts[1].to_string();
|
|
155
|
+
match method.as_str() {
|
|
156
|
+
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" => {}
|
|
157
|
+
_ => return None,
|
|
158
|
+
}
|
|
159
|
+
Some((method, path))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Parse a URL path into segments
|
|
163
|
+
pub fn parse_path_segments(path: &str) -> Vec<PathSegment> {
|
|
164
|
+
path.split('/')
|
|
165
|
+
.filter(|s| !s.is_empty())
|
|
166
|
+
.map(|s| {
|
|
167
|
+
if s.starts_with(':') {
|
|
168
|
+
PathSegment::Param(s[1..].to_string())
|
|
169
|
+
} else if s.starts_with('*') {
|
|
170
|
+
PathSegment::Wildcard(
|
|
171
|
+
if s.len() > 1 { s[1..].to_string() } else { "wildcard".to_string() },
|
|
172
|
+
)
|
|
173
|
+
} else {
|
|
174
|
+
PathSegment::Static(s.to_string())
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
.collect()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn extract_route_contract_type_params(
|
|
181
|
+
prop: &TsPropertySignature,
|
|
182
|
+
) -> (RouteTypeInfo, RouteTypeInfo, RouteTypeInfo, RouteTypeInfo) {
|
|
183
|
+
let default = (
|
|
184
|
+
RouteTypeInfo::Void,
|
|
185
|
+
RouteTypeInfo::Void,
|
|
186
|
+
RouteTypeInfo::Void,
|
|
187
|
+
RouteTypeInfo::Void,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
let type_ann = match &prop.type_ann {
|
|
191
|
+
Some(ann) => &ann.type_ann,
|
|
192
|
+
None => return default,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if let TsType::TsTypeRef(type_ref) = &**type_ann {
|
|
196
|
+
let name = match &type_ref.type_name {
|
|
197
|
+
TsEntityName::Ident(ident) => ident.sym.to_string(),
|
|
198
|
+
_ => return default,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if name != "RouteContract" {
|
|
202
|
+
return default;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if let Some(type_params) = &type_ref.type_params {
|
|
206
|
+
let infos: Vec<RouteTypeInfo> = type_params
|
|
207
|
+
.params
|
|
208
|
+
.iter()
|
|
209
|
+
.map(|p| ts_type_to_route_info(p))
|
|
210
|
+
.collect();
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
infos.first().cloned().unwrap_or(RouteTypeInfo::Void),
|
|
214
|
+
infos.get(1).cloned().unwrap_or(RouteTypeInfo::Void),
|
|
215
|
+
infos.get(2).cloned().unwrap_or(RouteTypeInfo::Void),
|
|
216
|
+
infos.get(3).cloned().unwrap_or(RouteTypeInfo::Void),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
default
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Convert a TsType AST node to a RouteTypeInfo representation
|
|
225
|
+
pub fn ts_type_to_route_info(ts_type: &TsType) -> RouteTypeInfo {
|
|
226
|
+
match ts_type {
|
|
227
|
+
TsType::TsKeywordType(kw) => match kw.kind {
|
|
228
|
+
TsKeywordTypeKind::TsVoidKeyword => RouteTypeInfo::Void,
|
|
229
|
+
TsKeywordTypeKind::TsStringKeyword => RouteTypeInfo::Primitive("string".into()),
|
|
230
|
+
TsKeywordTypeKind::TsNumberKeyword => RouteTypeInfo::Primitive("number".into()),
|
|
231
|
+
TsKeywordTypeKind::TsBooleanKeyword => RouteTypeInfo::Primitive("boolean".into()),
|
|
232
|
+
TsKeywordTypeKind::TsAnyKeyword => RouteTypeInfo::Primitive("any".into()),
|
|
233
|
+
TsKeywordTypeKind::TsUnknownKeyword => RouteTypeInfo::Primitive("unknown".into()),
|
|
234
|
+
TsKeywordTypeKind::TsNeverKeyword => RouteTypeInfo::Primitive("never".into()),
|
|
235
|
+
TsKeywordTypeKind::TsNullKeyword => RouteTypeInfo::Primitive("null".into()),
|
|
236
|
+
TsKeywordTypeKind::TsUndefinedKeyword => RouteTypeInfo::Primitive("undefined".into()),
|
|
237
|
+
_ => RouteTypeInfo::Primitive("unknown".into()),
|
|
238
|
+
},
|
|
239
|
+
TsType::TsTypeRef(type_ref) => {
|
|
240
|
+
let name = match &type_ref.type_name {
|
|
241
|
+
TsEntityName::Ident(ident) => ident.sym.to_string(),
|
|
242
|
+
TsEntityName::TsQualifiedName(qn) => qualified_name_to_string(qn),
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if let Some(params) = &type_ref.type_params {
|
|
246
|
+
let args: Vec<RouteTypeInfo> =
|
|
247
|
+
params.params.iter().map(|p| ts_type_to_route_info(p)).collect();
|
|
248
|
+
|
|
249
|
+
// Normalize Array<T> to Array(T)
|
|
250
|
+
if name == "Array" && args.len() == 1 {
|
|
251
|
+
return RouteTypeInfo::Array(Box::new(args.into_iter().next().unwrap()));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
RouteTypeInfo::Generic(name, args)
|
|
255
|
+
} else {
|
|
256
|
+
RouteTypeInfo::Named(name)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
TsType::TsArrayType(arr) => {
|
|
260
|
+
RouteTypeInfo::Array(Box::new(ts_type_to_route_info(&arr.elem_type)))
|
|
261
|
+
}
|
|
262
|
+
TsType::TsTypeLit(type_lit) => {
|
|
263
|
+
let props = type_lit
|
|
264
|
+
.members
|
|
265
|
+
.iter()
|
|
266
|
+
.filter_map(|m| {
|
|
267
|
+
if let TsTypeElement::TsPropertySignature(prop) = m {
|
|
268
|
+
let name = match &*prop.key {
|
|
269
|
+
Expr::Ident(ident) => ident.sym.to_string(),
|
|
270
|
+
_ => return None,
|
|
271
|
+
};
|
|
272
|
+
let type_info = prop
|
|
273
|
+
.type_ann
|
|
274
|
+
.as_ref()
|
|
275
|
+
.map(|ann| ts_type_to_route_info(&ann.type_ann))
|
|
276
|
+
.unwrap_or(RouteTypeInfo::Primitive("unknown".into()));
|
|
277
|
+
Some(RouteObjectProp {
|
|
278
|
+
name,
|
|
279
|
+
type_info,
|
|
280
|
+
optional: prop.optional,
|
|
281
|
+
})
|
|
282
|
+
} else {
|
|
283
|
+
None
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
.collect();
|
|
287
|
+
RouteTypeInfo::ObjectLiteral(props)
|
|
288
|
+
}
|
|
289
|
+
TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) => {
|
|
290
|
+
RouteTypeInfo::Union(
|
|
291
|
+
union.types.iter().map(|t| ts_type_to_route_info(t)).collect(),
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
TsType::TsLitType(lit) => match &lit.lit {
|
|
295
|
+
TsLit::Str(s) => RouteTypeInfo::StringLiteral(s.value.to_string_lossy().to_string()),
|
|
296
|
+
TsLit::Number(n) => RouteTypeInfo::NumberLiteral(n.value),
|
|
297
|
+
TsLit::Bool(b) => RouteTypeInfo::BooleanLiteral(b.value),
|
|
298
|
+
_ => RouteTypeInfo::Primitive("unknown".into()),
|
|
299
|
+
},
|
|
300
|
+
TsType::TsParenthesizedType(paren) => ts_type_to_route_info(&paren.type_ann),
|
|
301
|
+
_ => RouteTypeInfo::Primitive("unknown".into()),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fn qualified_name_to_string(qn: &TsQualifiedName) -> String {
|
|
306
|
+
let left = match &qn.left {
|
|
307
|
+
TsEntityName::Ident(ident) => ident.sym.to_string(),
|
|
308
|
+
TsEntityName::TsQualifiedName(qn) => qualified_name_to_string(qn),
|
|
309
|
+
};
|
|
310
|
+
format!("{}.{}", left, qn.right.sym)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Radix Tree Construction ─────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/// Build a radix tree from route entries.
|
|
316
|
+
/// Returns an error on ambiguous routes (conflicting param names at same level).
|
|
317
|
+
pub fn build_radix_tree(entries: &[RouteEntry]) -> Result<RouteNode, String> {
|
|
318
|
+
let mut root = RouteNode::new("");
|
|
319
|
+
|
|
320
|
+
for entry in entries {
|
|
321
|
+
insert_route(
|
|
322
|
+
&mut root,
|
|
323
|
+
&entry.segments,
|
|
324
|
+
0,
|
|
325
|
+
&entry.method,
|
|
326
|
+
&RouteHandlerEntry {
|
|
327
|
+
ref_str: entry.handler_ref.clone(),
|
|
328
|
+
middleware: vec![],
|
|
329
|
+
},
|
|
330
|
+
)?;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
Ok(root)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
fn insert_route(
|
|
337
|
+
node: &mut RouteNode,
|
|
338
|
+
segments: &[PathSegment],
|
|
339
|
+
index: usize,
|
|
340
|
+
method: &str,
|
|
341
|
+
handler: &RouteHandlerEntry,
|
|
342
|
+
) -> Result<(), String> {
|
|
343
|
+
if index >= segments.len() {
|
|
344
|
+
let handlers = node.handlers.get_or_insert_with(BTreeMap::new);
|
|
345
|
+
if handlers.contains_key(method) {
|
|
346
|
+
return Err(format!(
|
|
347
|
+
"Duplicate route: {} handler already defined at this path",
|
|
348
|
+
method
|
|
349
|
+
));
|
|
350
|
+
}
|
|
351
|
+
handlers.insert(method.to_string(), handler.clone());
|
|
352
|
+
return Ok(());
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
match &segments[index] {
|
|
356
|
+
PathSegment::Static(name) => {
|
|
357
|
+
let children = node.children.get_or_insert_with(BTreeMap::new);
|
|
358
|
+
let child = children
|
|
359
|
+
.entry(name.clone())
|
|
360
|
+
.or_insert_with(|| RouteNode::new(name));
|
|
361
|
+
insert_route(child, segments, index + 1, method, handler)
|
|
362
|
+
}
|
|
363
|
+
PathSegment::Param(param_name) => {
|
|
364
|
+
if let Some(existing) = &node.param_child {
|
|
365
|
+
let existing_name = existing.param_name.as_ref().unwrap();
|
|
366
|
+
if existing_name != param_name {
|
|
367
|
+
return Err(format!(
|
|
368
|
+
"Ambiguous route: param ':{}' conflicts with existing param ':{}' at the same path level",
|
|
369
|
+
param_name, existing_name
|
|
370
|
+
));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if node.param_child.is_none() {
|
|
375
|
+
let mut param_node = RouteNode::new("");
|
|
376
|
+
param_node.param_name = Some(param_name.clone());
|
|
377
|
+
node.param_child = Some(Box::new(param_node));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
insert_route(
|
|
381
|
+
node.param_child.as_mut().unwrap(),
|
|
382
|
+
segments,
|
|
383
|
+
index + 1,
|
|
384
|
+
method,
|
|
385
|
+
handler,
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
PathSegment::Wildcard(param_name) => {
|
|
389
|
+
if node.wildcard_child.is_some() {
|
|
390
|
+
return Err(format!(
|
|
391
|
+
"Ambiguous route: wildcard '*{}' conflicts with existing wildcard at the same path level",
|
|
392
|
+
param_name
|
|
393
|
+
));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let mut wildcard_node = RouteNode::new("");
|
|
397
|
+
wildcard_node.param_name = Some(param_name.clone());
|
|
398
|
+
let handlers = wildcard_node.handlers.get_or_insert_with(BTreeMap::new);
|
|
399
|
+
handlers.insert(method.to_string(), handler.clone());
|
|
400
|
+
node.wildcard_child = Some(Box::new(wildcard_node));
|
|
401
|
+
Ok(())
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── TypeScript Serialization ────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/// Serialize the compiled route tree as TypeScript code.
|
|
409
|
+
pub fn serialize_to_typescript(root: &RouteNode) -> String {
|
|
410
|
+
let json = serde_json::to_string_pretty(root).unwrap();
|
|
411
|
+
format!(
|
|
412
|
+
"// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n\
|
|
413
|
+
import type {{ CompiledRouteTable }} from \"@typokit/types\";\n\
|
|
414
|
+
\n\
|
|
415
|
+
export const routeTree: CompiledRouteTable = {} as const;\n",
|
|
416
|
+
json
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#[cfg(test)]
|
|
421
|
+
mod tests {
|
|
422
|
+
use super::*;
|
|
423
|
+
use crate::parser::parse_typescript;
|
|
424
|
+
|
|
425
|
+
#[test]
|
|
426
|
+
fn test_extract_route_contracts_basic() {
|
|
427
|
+
let source = r#"
|
|
428
|
+
interface UsersRoutes {
|
|
429
|
+
"GET /users": RouteContract<void, void, void, PublicUser[]>;
|
|
430
|
+
"POST /users": RouteContract<void, void, CreateUserInput, PublicUser>;
|
|
431
|
+
"GET /users/:id": RouteContract<{ id: string }, void, void, PublicUser>;
|
|
432
|
+
}
|
|
433
|
+
"#;
|
|
434
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
435
|
+
let entries = extract_route_contracts(&parsed.module);
|
|
436
|
+
|
|
437
|
+
assert_eq!(entries.len(), 3);
|
|
438
|
+
assert_eq!(entries[0].method, "GET");
|
|
439
|
+
assert_eq!(entries[0].path, "/users");
|
|
440
|
+
assert_eq!(entries[1].method, "POST");
|
|
441
|
+
assert_eq!(entries[1].path, "/users");
|
|
442
|
+
assert_eq!(entries[2].method, "GET");
|
|
443
|
+
assert_eq!(entries[2].path, "/users/:id");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#[test]
|
|
447
|
+
fn test_extract_route_contracts_with_query_params() {
|
|
448
|
+
let source = r#"
|
|
449
|
+
interface UsersRoutes {
|
|
450
|
+
"GET /users": RouteContract<void, { page?: number; pageSize?: number }, void, void>;
|
|
451
|
+
}
|
|
452
|
+
"#;
|
|
453
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
454
|
+
let entries = extract_route_contracts(&parsed.module);
|
|
455
|
+
|
|
456
|
+
assert_eq!(entries.len(), 1);
|
|
457
|
+
match &entries[0].query_type {
|
|
458
|
+
RouteTypeInfo::ObjectLiteral(props) => {
|
|
459
|
+
assert_eq!(props.len(), 2);
|
|
460
|
+
assert_eq!(props[0].name, "page");
|
|
461
|
+
assert!(props[0].optional);
|
|
462
|
+
assert_eq!(props[1].name, "pageSize");
|
|
463
|
+
assert!(props[1].optional);
|
|
464
|
+
}
|
|
465
|
+
_ => panic!("Expected ObjectLiteral for query type"),
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#[test]
|
|
470
|
+
fn test_extract_route_contracts_exported_interface() {
|
|
471
|
+
let source = r#"
|
|
472
|
+
export interface HealthRoutes {
|
|
473
|
+
"GET /health": RouteContract<void, void, void, { status: string }>;
|
|
474
|
+
}
|
|
475
|
+
"#;
|
|
476
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
477
|
+
let entries = extract_route_contracts(&parsed.module);
|
|
478
|
+
|
|
479
|
+
assert_eq!(entries.len(), 1);
|
|
480
|
+
assert_eq!(entries[0].method, "GET");
|
|
481
|
+
assert_eq!(entries[0].path, "/health");
|
|
482
|
+
assert!(entries[0].handler_ref.contains("HealthRoutes"));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#[test]
|
|
486
|
+
fn test_extract_ignores_non_route_interfaces() {
|
|
487
|
+
let source = r#"
|
|
488
|
+
interface User {
|
|
489
|
+
id: string;
|
|
490
|
+
name: string;
|
|
491
|
+
}
|
|
492
|
+
interface UsersRoutes {
|
|
493
|
+
"GET /users": RouteContract<void, void, void, void>;
|
|
494
|
+
}
|
|
495
|
+
"#;
|
|
496
|
+
let parsed = parse_typescript("test.ts", source).unwrap();
|
|
497
|
+
let entries = extract_route_contracts(&parsed.module);
|
|
498
|
+
|
|
499
|
+
assert_eq!(entries.len(), 1);
|
|
500
|
+
assert_eq!(entries[0].path, "/users");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#[test]
|
|
504
|
+
fn test_build_radix_tree_basic() {
|
|
505
|
+
let entries = vec![
|
|
506
|
+
make_entry("GET", "/users"),
|
|
507
|
+
make_entry("POST", "/users"),
|
|
508
|
+
make_entry("GET", "/users/:id"),
|
|
509
|
+
make_entry("GET", "/health"),
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
let tree = build_radix_tree(&entries).unwrap();
|
|
513
|
+
|
|
514
|
+
assert_eq!(tree.segment, "");
|
|
515
|
+
let children = tree.children.as_ref().unwrap();
|
|
516
|
+
assert!(children.contains_key("users"));
|
|
517
|
+
assert!(children.contains_key("health"));
|
|
518
|
+
|
|
519
|
+
let users = &children["users"];
|
|
520
|
+
let handlers = users.handlers.as_ref().unwrap();
|
|
521
|
+
assert!(handlers.contains_key("GET"));
|
|
522
|
+
assert!(handlers.contains_key("POST"));
|
|
523
|
+
|
|
524
|
+
let param_child = users.param_child.as_ref().unwrap();
|
|
525
|
+
assert_eq!(param_child.param_name, Some("id".to_string()));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#[test]
|
|
529
|
+
fn test_build_radix_tree_nested_params() {
|
|
530
|
+
let entries = vec![make_entry("GET", "/users/:userId/posts/:postId")];
|
|
531
|
+
|
|
532
|
+
let tree = build_radix_tree(&entries).unwrap();
|
|
533
|
+
|
|
534
|
+
let users = &tree.children.as_ref().unwrap()["users"];
|
|
535
|
+
let user_param = users.param_child.as_ref().unwrap();
|
|
536
|
+
assert_eq!(user_param.param_name, Some("userId".to_string()));
|
|
537
|
+
|
|
538
|
+
let posts = &user_param.children.as_ref().unwrap()["posts"];
|
|
539
|
+
let post_param = posts.param_child.as_ref().unwrap();
|
|
540
|
+
assert_eq!(post_param.param_name, Some("postId".to_string()));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#[test]
|
|
544
|
+
fn test_build_radix_tree_ambiguous_params() {
|
|
545
|
+
let entries = vec![
|
|
546
|
+
make_entry("GET", "/users/:id"),
|
|
547
|
+
make_entry("GET", "/users/:userId/posts"),
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
let result = build_radix_tree(&entries);
|
|
551
|
+
assert!(result.is_err());
|
|
552
|
+
assert!(result.unwrap_err().contains("Ambiguous"));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
#[test]
|
|
556
|
+
fn test_build_radix_tree_wildcard() {
|
|
557
|
+
let entries = vec![make_entry("GET", "/files/*path")];
|
|
558
|
+
|
|
559
|
+
let tree = build_radix_tree(&entries).unwrap();
|
|
560
|
+
let files = &tree.children.as_ref().unwrap()["files"];
|
|
561
|
+
let wildcard = files.wildcard_child.as_ref().unwrap();
|
|
562
|
+
assert_eq!(wildcard.param_name, Some("path".to_string()));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
#[test]
|
|
566
|
+
fn test_build_radix_tree_static_and_param_coexist() {
|
|
567
|
+
let entries = vec![
|
|
568
|
+
make_entry("GET", "/users/me"),
|
|
569
|
+
make_entry("GET", "/users/:id"),
|
|
570
|
+
];
|
|
571
|
+
|
|
572
|
+
let tree = build_radix_tree(&entries).unwrap();
|
|
573
|
+
let users = &tree.children.as_ref().unwrap()["users"];
|
|
574
|
+
|
|
575
|
+
// Static "me" child
|
|
576
|
+
assert!(users.children.as_ref().unwrap().contains_key("me"));
|
|
577
|
+
// Param child
|
|
578
|
+
assert!(users.param_child.is_some());
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#[test]
|
|
582
|
+
fn test_serialize_to_typescript() {
|
|
583
|
+
let entries = vec![make_entry("GET", "/health")];
|
|
584
|
+
let tree = build_radix_tree(&entries).unwrap();
|
|
585
|
+
let ts = serialize_to_typescript(&tree);
|
|
586
|
+
|
|
587
|
+
assert!(ts.contains("AUTO-GENERATED"));
|
|
588
|
+
assert!(ts.contains("CompiledRouteTable"));
|
|
589
|
+
assert!(ts.contains("routeTree"));
|
|
590
|
+
assert!(ts.contains("health"));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#[test]
|
|
594
|
+
fn test_duplicate_handler_error() {
|
|
595
|
+
let entries = vec![make_entry("GET", "/users"), make_entry("GET", "/users")];
|
|
596
|
+
|
|
597
|
+
let result = build_radix_tree(&entries);
|
|
598
|
+
assert!(result.is_err());
|
|
599
|
+
assert!(result.unwrap_err().contains("Duplicate"));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
fn make_entry(method: &str, path: &str) -> RouteEntry {
|
|
603
|
+
let segments = parse_path_segments(path);
|
|
604
|
+
RouteEntry {
|
|
605
|
+
method: method.to_string(),
|
|
606
|
+
path: path.to_string(),
|
|
607
|
+
segments,
|
|
608
|
+
handler_ref: format!("test#{} {}", method, path),
|
|
609
|
+
params_type: RouteTypeInfo::Void,
|
|
610
|
+
query_type: RouteTypeInfo::Void,
|
|
611
|
+
body_type: RouteTypeInfo::Void,
|
|
612
|
+
response_type: RouteTypeInfo::Void,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|