mcp_execution_codegen/common/typescript.rs
1//! TypeScript code generation utilities.
2//!
3//! Provides functions to convert JSON Schema to TypeScript types
4//! and generate type-safe TypeScript code.
5//!
6//! # Examples
7//!
8//! ```
9//! use mcp_execution_codegen::common::typescript;
10//! use serde_json::json;
11//!
12//! let schema = json!({
13//! "type": "object",
14//! "properties": {
15//! "name": {"type": "string"},
16//! "age": {"type": "number"}
17//! }
18//! });
19//!
20//! let ts_type = typescript::json_schema_to_typescript(&schema);
21//! ```
22
23use serde_json::Value;
24use std::collections::HashSet;
25
26/// Maximum nesting depth [`json_schema_to_typescript`] will descend into before treating the
27/// remainder of a branch as opaque, rather than recursing further.
28///
29/// The `JSDoc` description sanitizer in `progressive::generator` uses the same cap. Both
30/// functions recurse into every nested `object`/`array` schema with no depth limit of
31/// their own, and `mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES` bounds a schema's
32/// *serialized byte size*, not its nesting depth — so nothing in this crate stops a caller from
33/// handing either function an arbitrarily deep, directly-constructed `serde_json::Value` (e.g.
34/// built by hand via `Value::Object`/`Map`, as in this module's own tests). That is the actual
35/// threat this constant defends against: **not** a schema arriving over the wire from a target
36/// MCP server. `input_schema` there is deserialized by `serde_json` from a `tools/list`
37/// response, and this workspace never raises or disables that deserializer's own default
38/// recursion limit (128 — no `disable_recursion_limit`/`unbounded_depth` anywhere in the
39/// dependency tree). Measured through a real `tools/list` envelope, that limit caps *reachable*
40/// nesting at 122 levels for an array-shaped schema and ~61 levels for `properties`-nested
41/// objects (each object level costs two parser recursion levels: the wrapper and its
42/// `properties` map) — a server that sends anything deeper gets its response line dropped as a
43/// parse error before `mcp-execution-introspector` ever builds a `ToolInfo` from it, let alone
44/// hands it to this crate. So on the wire path, this cap can never fire, let alone protect
45/// against a stack overflow.
46///
47/// This cap is therefore defense-in-depth for direct callers of these two functions — bounding
48/// a `Value` handed straight to `json_schema_to_typescript` or the `JSDoc` sanitizer, regardless
49/// of provenance — not a fix for a reachable wire-path denial of service, and **not** a
50/// guarantee that covers the rest of the `ProgressiveGenerator::generate`/
51/// `generate_with_categories` pipeline built on top of them. That pipeline has other
52/// unconditionally-recursive touches on the same schema this cap does not reach: e.g.
53/// `progressive::generator`'s `create_tool_context` clones `tool.input_schema` (an
54/// unguarded, recursive `Value::Clone`) before ever calling the capped sanitizer, and the
55/// schema is later re-serialized (`serde_json::to_vec`) and rendered through Handlebars, and
56/// eventually dropped (`Value`'s `Drop` impl is itself recursive) — none of which this constant
57/// bounds. 128 is set at the wire path's own ceiling (comfortably above the 122 levels a real
58/// `tools/list` response can ever produce, so no schema that actually arrives via introspection
59/// is ever clipped) while still bounding the unconditionally-recursive descent for a `Value`
60/// passed directly to either of these two functions.
61///
62/// That ceiling is coupled to `serde_json`'s own default recursion limit staying at 128: this
63/// value isn't an arbitrary round number, it's chosen to sit at the wire path's current ceiling
64/// specifically because that upstream default is 128. If a future `serde_json` release changes
65/// its own default, or this workspace ever configures a different limit, this constant's "never
66/// clips a real wire schema" property needs re-verifying against the new ceiling.
67///
68/// # Examples
69///
70/// ```
71/// use mcp_execution_codegen::common::typescript::MAX_SCHEMA_RECURSION_DEPTH;
72///
73/// assert!(MAX_SCHEMA_RECURSION_DEPTH > 0);
74/// ```
75pub const MAX_SCHEMA_RECURSION_DEPTH: usize = 128;
76
77/// Converts a `snake_case` name to camelCase for TypeScript.
78///
79/// # Examples
80///
81/// ```
82/// use mcp_execution_codegen::common::typescript::to_camel_case;
83///
84/// assert_eq!(to_camel_case("send_message"), "sendMessage");
85/// assert_eq!(to_camel_case("get_user_data"), "getUserData");
86/// assert_eq!(to_camel_case("hello"), "hello");
87/// ```
88#[must_use]
89pub fn to_camel_case(snake_case: &str) -> String {
90 let mut result = String::new();
91 let mut capitalize_next = false;
92
93 for ch in snake_case.chars() {
94 if ch == '_' {
95 capitalize_next = true;
96 } else if capitalize_next {
97 result.push(ch.to_ascii_uppercase());
98 capitalize_next = false;
99 } else {
100 result.push(ch);
101 }
102 }
103
104 result
105}
106
107/// Converts a `snake_case` name to `PascalCase` for TypeScript types.
108///
109/// # Examples
110///
111/// ```
112/// use mcp_execution_codegen::common::typescript::to_pascal_case;
113///
114/// assert_eq!(to_pascal_case("send_message"), "SendMessage");
115/// assert_eq!(to_pascal_case("get_user_data"), "GetUserData");
116/// assert_eq!(to_pascal_case("hello"), "Hello");
117/// ```
118#[must_use]
119pub fn to_pascal_case(snake_case: &str) -> String {
120 let camel = to_camel_case(snake_case);
121 let mut chars = camel.chars();
122 chars.next().map_or_else(String::new, |first| {
123 first.to_uppercase().collect::<String>() + chars.as_str()
124 })
125}
126
127/// Sanitizes a string for safe use as a TypeScript identifier (e.g. a function, export,
128/// or object property name).
129///
130/// Characters in `[A-Za-z0-9$]` pass through unchanged; every other character — including a
131/// literal `_` already in the input — is treated as a separator and replaces its whole
132/// consecutive run with a single `_`, and the result is prefixed with `_` if it would
133/// otherwise start with a digit or be empty. This prevents identifier-position injection of
134/// arbitrary TypeScript syntax from untrusted schema data (property keys, tool names, etc.
135/// sourced from an MCP server are not guaranteed to be valid identifiers).
136///
137/// Collapsing an entire separator run — rather than emitting one `_` per invalid character —
138/// means e.g. a multi-byte non-ASCII run doesn't balloon into a wall of underscores that
139/// discards more information than a single separator already would. It also means a literal
140/// run like `"a__b"` collapses to `"a_b"`: once a separator has been emitted, further
141/// separator-producing characters are redundant regardless of why each one would have
142/// produced a `_`.
143///
144/// # Examples
145///
146/// ```
147/// use mcp_execution_codegen::common::typescript::sanitize_ts_identifier;
148///
149/// assert_eq!(sanitize_ts_identifier("valid_name"), "valid_name");
150/// assert_eq!(sanitize_ts_identifier("123abc"), "_123abc");
151/// assert_eq!(sanitize_ts_identifier("a-b c"), "a_b_c");
152/// assert_eq!(sanitize_ts_identifier("café_menu_日本語"), "caf_menu_");
153/// ```
154#[must_use]
155pub fn sanitize_ts_identifier(s: &str) -> String {
156 let mut result = String::new();
157 let mut prev_was_underscore = false;
158 for c in s.chars() {
159 if c.is_ascii_alphanumeric() || c == '$' {
160 result.push(c);
161 prev_was_underscore = false;
162 } else if !prev_was_underscore {
163 result.push('_');
164 prev_was_underscore = true;
165 }
166 }
167
168 if result.is_empty() || result.starts_with(|c: char| c.is_ascii_digit()) {
169 result.insert(0, '_');
170 }
171
172 result
173}
174
175/// Disambiguates `base` against the `used` set by appending a numeric suffix
176/// (`_2`, `_3`, ...) until the candidate is not already present, then reserves the
177/// winning candidate by inserting it into `used`.
178///
179/// Shared by every call site that maps untrusted, sanitized names into a namespace where
180/// distinct source names can collide after sanitization (e.g. sibling schema keys or tool
181/// names) — collisions must be disambiguated deterministically rather than silently
182/// overwriting one another.
183pub(crate) fn disambiguate_identifier(base: &str, used: &mut HashSet<String>) -> String {
184 let mut candidate = base.to_string();
185 let mut suffix = 2;
186 while !used.insert(candidate.clone()) {
187 candidate = format!("{base}_{suffix}");
188 suffix += 1;
189 }
190 candidate
191}
192
193/// Converts JSON Schema type to TypeScript type.
194///
195/// Maps JSON Schema primitive types to their TypeScript equivalents.
196///
197/// # Examples
198///
199/// ```
200/// use mcp_execution_codegen::common::typescript::json_type_to_typescript;
201///
202/// assert_eq!(json_type_to_typescript("string"), "string");
203/// assert_eq!(json_type_to_typescript("number"), "number");
204/// assert_eq!(json_type_to_typescript("integer"), "number");
205/// assert_eq!(json_type_to_typescript("boolean"), "boolean");
206/// assert_eq!(json_type_to_typescript("unknown_type"), "unknown");
207/// ```
208#[must_use]
209pub fn json_type_to_typescript(json_type: &str) -> &'static str {
210 match json_type {
211 "string" => "string",
212 "number" | "integer" => "number",
213 "boolean" => "boolean",
214 "array" => "unknown[]",
215 "object" => "Record<string, unknown>",
216 "null" => "null",
217 _ => "unknown",
218 }
219}
220
221/// Converts a JSON Schema to TypeScript type definition.
222///
223/// Handles complex schemas including objects, arrays, and nested types.
224///
225/// # Examples
226///
227/// ```
228/// use mcp_execution_codegen::common::typescript::json_schema_to_typescript;
229/// use serde_json::json;
230///
231/// let schema = json!({
232/// "type": "object",
233/// "properties": {
234/// "name": {"type": "string"},
235/// "age": {"type": "number"}
236/// },
237/// "required": ["name"]
238/// });
239///
240/// let ts = json_schema_to_typescript(&schema);
241/// assert!(ts.contains("name: string"));
242/// ```
243#[must_use]
244pub fn json_schema_to_typescript(schema: &Value) -> String {
245 let mut cap_hit = false;
246 let ts_type = json_schema_to_typescript_at_depth(schema, 0, &mut cap_hit);
247 if cap_hit {
248 // Known limitation: `extract_properties` calls this function once per top-level
249 // property, so a schema whose properties each nest deeply logs once per affected
250 // property rather than once per tool, and this warning carries no tool/server
251 // identifier to correlate it back to the originating `generate_with_categories` call.
252 tracing::warn!(
253 max_depth = MAX_SCHEMA_RECURSION_DEPTH,
254 "schema nesting exceeded MAX_SCHEMA_RECURSION_DEPTH; branches beyond that depth were \
255 rendered as an opaque `unknown` type"
256 );
257 }
258 ts_type
259}
260
261/// Depth-tracked implementation backing [`json_schema_to_typescript`].
262///
263/// Once `depth` reaches [`MAX_SCHEMA_RECURSION_DEPTH`], the current branch is treated as
264/// opaque (`unknown`/`unknown[]`) instead of recursing further — see that constant's docs for
265/// what this cap actually defends against. `cap_hit` is set (never cleared) the first time any
266/// branch trips the cap, so the public wrapper can log once per call rather than once per
267/// clipped branch.
268fn json_schema_to_typescript_at_depth(schema: &Value, depth: usize, cap_hit: &mut bool) -> String {
269 if depth >= MAX_SCHEMA_RECURSION_DEPTH {
270 *cap_hit = true;
271 return "unknown".to_string();
272 }
273
274 match schema {
275 Value::Object(obj) => {
276 // Get type field
277 let schema_type = obj
278 .get("type")
279 .and_then(|v| v.as_str())
280 .unwrap_or("unknown");
281
282 match schema_type {
283 "object" => {
284 // Extract properties
285 let properties = obj.get("properties").and_then(|v| v.as_object());
286 let required = obj
287 .get("required")
288 .and_then(|v| v.as_array())
289 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
290 .unwrap_or_default();
291
292 properties.map_or_else(
293 || "Record<string, unknown>".to_string(),
294 |props| {
295 let mut fields = Vec::new();
296 let mut used_keys = HashSet::new();
297 for (key, value) in props {
298 let is_required = required.contains(&key.as_str());
299 let optional_marker = if is_required { "" } else { "?" };
300 let ts_type =
301 json_schema_to_typescript_at_depth(value, depth + 1, cap_hit);
302 let base_key = sanitize_ts_identifier(key);
303 let safe_key = disambiguate_identifier(&base_key, &mut used_keys);
304 fields.push(format!(" {safe_key}{optional_marker}: {ts_type};"));
305 }
306
307 if fields.is_empty() {
308 "Record<string, unknown>".to_string()
309 } else {
310 format!("{{\n{}\n}}", fields.join("\n"))
311 }
312 },
313 )
314 }
315 "array" => obj.get("items").map_or_else(
316 || "unknown[]".to_string(),
317 |item_schema| {
318 format!(
319 "{}[]",
320 json_schema_to_typescript_at_depth(item_schema, depth + 1, cap_hit)
321 )
322 },
323 ),
324 other => json_type_to_typescript(other).to_string(),
325 }
326 }
327 Value::String(s) => json_type_to_typescript(s).to_string(),
328 _ => "unknown".to_string(),
329 }
330}
331
332/// Extracts property definitions from JSON Schema for template rendering.
333///
334/// Returns a vector of property information suitable for Handlebars templates.
335///
336/// Calls [`json_schema_to_typescript`] once per top-level property, starting each call fresh
337/// at depth 0 — the `schema`'s own `type: "object"` wrapper and `properties` map are peeled off
338/// by this function rather than counted by [`MAX_SCHEMA_RECURSION_DEPTH`]. So the real
339/// per-tool call path (`ProgressiveGenerator::extract_property_infos` /
340/// `extract_property_data`, both built on this function) tolerates one level more of true JSON
341/// nesting in a tool's raw `input_schema` than the constant's value alone would suggest: a
342/// property value schema can nest `MAX_SCHEMA_RECURSION_DEPTH` levels *below* the property
343/// itself before clipping, for `MAX_SCHEMA_RECURSION_DEPTH + 1` raw levels overall.
344///
345/// # Examples
346///
347/// ```
348/// use mcp_execution_codegen::common::typescript::extract_properties;
349/// use serde_json::json;
350///
351/// let schema = json!({
352/// "type": "object",
353/// "properties": {
354/// "name": {"type": "string"},
355/// "age": {"type": "number"}
356/// },
357/// "required": ["name"]
358/// });
359///
360/// let props = extract_properties(&schema);
361/// assert_eq!(props.len(), 2);
362/// ```
363#[must_use]
364pub fn extract_properties(schema: &Value) -> Vec<serde_json::Value> {
365 let mut properties = Vec::new();
366
367 if let Some(obj) = schema.as_object()
368 && let Some(props) = obj.get("properties").and_then(|v| v.as_object())
369 {
370 let required = obj
371 .get("required")
372 .and_then(|v| v.as_array())
373 .map(|arr| {
374 arr.iter()
375 .filter_map(|v| v.as_str())
376 .map(String::from)
377 .collect::<Vec<_>>()
378 })
379 .unwrap_or_default();
380
381 for (name, prop_schema) in props {
382 let ts_type = json_schema_to_typescript(prop_schema);
383 let is_required = required.contains(name);
384
385 properties.push(serde_json::json!({
386 "name": name,
387 "type": ts_type,
388 "required": is_required,
389 }));
390 }
391 }
392
393 properties
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use serde_json::json;
400
401 #[test]
402 fn test_to_camel_case() {
403 assert_eq!(to_camel_case("send_message"), "sendMessage");
404 assert_eq!(to_camel_case("get_user_data"), "getUserData");
405 assert_eq!(to_camel_case("hello"), "hello");
406 assert_eq!(to_camel_case("a_b_c"), "aBC");
407 }
408
409 #[test]
410 fn test_to_pascal_case() {
411 assert_eq!(to_pascal_case("send_message"), "SendMessage");
412 assert_eq!(to_pascal_case("get_user_data"), "GetUserData");
413 assert_eq!(to_pascal_case("hello"), "Hello");
414 }
415
416 #[test]
417 fn test_json_type_to_typescript() {
418 assert_eq!(json_type_to_typescript("string"), "string");
419 assert_eq!(json_type_to_typescript("number"), "number");
420 assert_eq!(json_type_to_typescript("integer"), "number");
421 assert_eq!(json_type_to_typescript("boolean"), "boolean");
422 assert_eq!(json_type_to_typescript("array"), "unknown[]");
423 assert_eq!(json_type_to_typescript("object"), "Record<string, unknown>");
424 assert_eq!(json_type_to_typescript("null"), "null");
425 assert_eq!(json_type_to_typescript("unknown_type"), "unknown");
426 }
427
428 #[test]
429 fn test_json_schema_to_typescript_primitive() {
430 assert_eq!(
431 json_schema_to_typescript(&json!({"type": "string"})),
432 "string"
433 );
434 assert_eq!(
435 json_schema_to_typescript(&json!({"type": "number"})),
436 "number"
437 );
438 }
439
440 #[test]
441 fn test_json_schema_to_typescript_object() {
442 let schema = json!({
443 "type": "object",
444 "properties": {
445 "name": {"type": "string"},
446 "age": {"type": "number"}
447 },
448 "required": ["name"]
449 });
450
451 let result = json_schema_to_typescript(&schema);
452 assert!(result.contains("name: string"));
453 assert!(result.contains("age?: number"));
454 }
455
456 #[test]
457 fn test_json_schema_to_typescript_object_sanitizes_nested_keys() {
458 let malicious_key = "x }; export const pwned = evil(); interface J {";
459 let schema = json!({
460 "type": "object",
461 "properties": {
462 malicious_key: {"type": "string"}
463 },
464 "required": []
465 });
466
467 let result = json_schema_to_typescript(&schema);
468 assert!(!result.contains("export const pwned"));
469 assert!(!result.contains(malicious_key));
470 assert!(result.contains(&sanitize_ts_identifier(malicious_key)));
471 }
472
473 #[test]
474 fn test_json_schema_to_typescript_dedups_colliding_sibling_keys() {
475 // "a-b" and "a.b" both sanitize to "a_b"; the second must be disambiguated
476 // rather than producing a duplicate field name in the generated interface.
477 let schema = json!({
478 "type": "object",
479 "properties": {
480 "a-b": {"type": "string"},
481 "a.b": {"type": "number"}
482 },
483 "required": []
484 });
485
486 let result = json_schema_to_typescript(&schema);
487 // Exact field-line matches: a substring count on "a_b" would also match "a_b_2",
488 // so assert on the full `key?: type` field lines instead.
489 assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
490 assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
491 }
492
493 #[test]
494 fn test_json_schema_to_typescript_dedups_three_way_colliding_sibling_keys() {
495 let schema = json!({
496 "type": "object",
497 "properties": {
498 "a-b": {"type": "string"},
499 "a.b": {"type": "number"},
500 "a b": {"type": "boolean"}
501 },
502 "required": []
503 });
504
505 let result = json_schema_to_typescript(&schema);
506 // The `preserve_order` feature is enabled transitively (via schemars/rmcp), so
507 // `serde_json::Map` iterates in insertion order: "a-b" claims the base "a_b" first.
508 assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
509 assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
510 assert_eq!(result.matches("a_b_3?: boolean").count(), 1, "{result}");
511 }
512
513 #[test]
514 fn test_json_schema_to_typescript_dedups_colliding_keys_in_nested_object() {
515 let schema = json!({
516 "type": "object",
517 "properties": {
518 "outer": {
519 "type": "object",
520 "properties": {
521 "a-b": {"type": "string"},
522 "a.b": {"type": "number"}
523 },
524 "required": []
525 }
526 },
527 "required": []
528 });
529
530 let result = json_schema_to_typescript(&schema);
531 assert!(result.contains("a_b?: string"), "{result}");
532 assert!(result.contains("a_b_2?: number"), "{result}");
533 }
534
535 #[test]
536 fn test_disambiguate_identifier_reuses_base_across_independent_scopes() {
537 // Disambiguation state must not leak between unrelated objects: two sibling nested
538 // objects each independently reusing "a_b" as a field name is not a collision.
539 let schema = json!({
540 "type": "object",
541 "properties": {
542 "first": {
543 "type": "object",
544 "properties": {"a_b": {"type": "string"}},
545 "required": []
546 },
547 "second": {
548 "type": "object",
549 "properties": {"a_b": {"type": "number"}},
550 "required": []
551 }
552 },
553 "required": []
554 });
555
556 let result = json_schema_to_typescript(&schema);
557 assert!(!result.contains("a_b_2"), "{result}");
558 }
559
560 #[test]
561 fn test_sanitize_ts_identifier_replaces_invalid_characters() {
562 assert_eq!(sanitize_ts_identifier("a-b c"), "a_b_c");
563 }
564
565 #[test]
566 fn test_sanitize_ts_identifier_prefixes_leading_digit() {
567 assert_eq!(sanitize_ts_identifier("123name"), "_123name");
568 }
569
570 #[test]
571 fn test_sanitize_ts_identifier_prefixes_empty_string() {
572 assert_eq!(sanitize_ts_identifier(""), "_");
573 }
574
575 #[test]
576 fn test_sanitize_ts_identifier_collapses_consecutive_non_ascii_run() {
577 // Issue #192: each invalid char used to become its own `_`, so one accented
578 // letter plus a run of CJK characters produced four separate underscores. The
579 // literal `_`s between words must also collapse into the run rather than
580 // surviving as a second, adjacent separator (otherwise `sanitize_ts_identifier`
581 // alone — as used for property names, without `to_camel_case` first — still
582 // produces `caf__menu__`, the exact artifact the issue was filed about).
583 assert_eq!(sanitize_ts_identifier("café_menu_日本語"), "caf_menu_");
584 }
585
586 #[test]
587 fn test_sanitize_ts_identifier_collapses_mixed_invalid_run() {
588 assert_eq!(sanitize_ts_identifier("a---b"), "a_b");
589 assert_eq!(sanitize_ts_identifier("a- .b"), "a_b");
590 }
591
592 #[test]
593 fn test_sanitize_ts_identifier_collapses_literal_underscore_runs() {
594 // A run of literal `_`s in the input collapses too: once a separator has been
595 // emitted, further separator-producing characters (whether literal `_` or a
596 // substituted invalid character) are redundant regardless of which kind they are.
597 assert_eq!(sanitize_ts_identifier("a__b"), "a_b");
598 assert_eq!(sanitize_ts_identifier("日_本"), "_");
599 assert_eq!(sanitize_ts_identifier("_日_"), "_");
600 }
601
602 #[test]
603 fn test_sanitize_ts_identifier_preserves_isolated_invalid_chars() {
604 // A single invalid character separated from the next by a valid character must still
605 // become its own `_` — only *consecutive* separator-producing runs collapse.
606 assert_eq!(sanitize_ts_identifier("a-b-c"), "a_b_c");
607 }
608
609 #[test]
610 fn test_sanitize_ts_identifier_all_invalid_chars_collapses_to_bare_underscore() {
611 // The most extreme case of issue #192: an identifier made entirely of invalid
612 // characters must collapse to a single `_`, not one `_` per character. Distinct
613 // from the empty-string case (`""` never enters the loop at all).
614 assert_eq!(sanitize_ts_identifier("日本語"), "_");
615 assert_eq!(sanitize_ts_identifier("---"), "_");
616 }
617
618 #[test]
619 fn test_json_schema_to_typescript_array() {
620 let schema = json!({
621 "type": "array",
622 "items": {"type": "string"}
623 });
624
625 assert_eq!(json_schema_to_typescript(&schema), "string[]");
626 }
627
628 /// Builds a schema with `depth` nested `type: "array"` levels wrapping a `string` leaf.
629 ///
630 /// Assembles each level directly via `serde_json::Map`/`Value` (rather than the `json!`
631 /// macro, which round-trips embedded values through `to_value`'s own recursive
632 /// serializer) so building this pathological fixture can't itself overflow the stack.
633 fn nested_array_schema(depth: usize) -> Value {
634 let mut schema = json!({"type": "string"});
635 for _ in 0..depth {
636 let mut map = serde_json::Map::new();
637 map.insert("type".to_string(), Value::String("array".to_string()));
638 map.insert("items".to_string(), schema);
639 schema = Value::Object(map);
640 }
641 schema
642 }
643
644 /// Builds a schema with `depth` nested `type: "object"` levels (each with a single
645 /// property `"a"`) wrapping a `string` leaf, for the same construction reason as
646 /// [`nested_array_schema`].
647 fn nested_object_schema(depth: usize) -> Value {
648 let mut schema = json!({"type": "string"});
649 for _ in 0..depth {
650 let mut properties = serde_json::Map::new();
651 properties.insert("a".to_string(), schema);
652 let mut map = serde_json::Map::new();
653 map.insert("type".to_string(), Value::String("object".to_string()));
654 map.insert("properties".to_string(), Value::Object(properties));
655 map.insert("required".to_string(), Value::Array(Vec::new()));
656 schema = Value::Object(map);
657 }
658 schema
659 }
660
661 /// Runs `f` on a dedicated thread with a generous stack, and returns its result.
662 ///
663 /// `serde_json::Value`'s own `Drop` impl is recursive and unrelated to this module's fix:
664 /// a `Value` nested 5,000+ levels deep overflows the *default* thread stack merely by
665 /// going out of scope, regardless of how it was traversed beforehand. That's not
666 /// reachable in production — `serde_json`'s deserializer already rejects JSON nested
667 /// beyond its own default recursion limit (well under 5,000) before a `Value` this deep
668 /// can ever be constructed from the wire — but a test fixture built directly via
669 /// `Value`/`Map` (bypassing deserialization, see [`nested_array_schema`]) still has to
670 /// survive being dropped at the end of the test. A larger stack lets construction, the
671 /// (now depth-capped) function under test, and teardown all complete without that
672 /// unrelated limitation masking a pass.
673 fn run_on_large_stack<F: FnOnce() + Send + 'static>(f: F) {
674 std::thread::Builder::new()
675 .stack_size(64 * 1024 * 1024)
676 .spawn(f)
677 .expect("spawn test thread")
678 .join()
679 .expect("test thread panicked");
680 }
681
682 #[test]
683 fn test_json_schema_to_typescript_bounds_deeply_nested_array() {
684 // Issue #303: verifies the recursion-depth cap actually bounds this function's
685 // descent for a `Value` nested far beyond MAX_SCHEMA_RECURSION_DEPTH (see that
686 // constant's docs — this is defense-in-depth for the `pub` API, not a reachable
687 // wire-path scenario). Past the cap, the type must degrade to a well-formed
688 // `unknown[]...[]` string rather than continuing to recurse.
689 run_on_large_stack(|| {
690 let schema = nested_array_schema(5_000);
691 let result = json_schema_to_typescript(&schema);
692 assert!(result.starts_with("unknown"), "{result}");
693 assert!(result.ends_with("[]"), "{result}");
694 });
695 }
696
697 #[test]
698 fn test_json_schema_to_typescript_bounds_deeply_nested_object() {
699 // Same cap-behavior check as the array case above, for object nesting.
700 run_on_large_stack(|| {
701 let schema = nested_object_schema(5_000);
702 let result = json_schema_to_typescript(&schema);
703 assert!(result.contains("unknown"), "{result}");
704 });
705 }
706
707 #[test]
708 fn test_extract_properties() {
709 let schema = json!({
710 "type": "object",
711 "properties": {
712 "name": {"type": "string"},
713 "age": {"type": "number"}
714 },
715 "required": ["name"]
716 });
717
718 let props = extract_properties(&schema);
719 assert_eq!(props.len(), 2);
720
721 // Find the "name" property (HashMap order is not guaranteed)
722 let name_prop = props
723 .iter()
724 .find(|p| p["name"] == "name")
725 .expect("name property not found");
726
727 assert_eq!(name_prop["type"], "string");
728 assert_eq!(name_prop["required"], true);
729
730 // Check age property
731 let age_prop = props
732 .iter()
733 .find(|p| p["name"] == "age")
734 .expect("age property not found");
735
736 assert_eq!(age_prop["type"], "number");
737 assert_eq!(age_prop["required"], false);
738 }
739
740 #[test]
741 fn test_extract_properties_empty() {
742 let schema = json!({"type": "string"});
743 let props = extract_properties(&schema);
744 assert_eq!(props.len(), 0);
745 }
746}