use crate::codegen::SchemaRegistry;
use crate::schema::{LiteralValue, Schema, SchemaKind};
use handlebars::Handlebars;
use serde::Serialize;
use std::collections::HashMap;
pub struct TypeScriptGenerator {
registry: Handlebars<'static>,
}
impl TypeScriptGenerator {
pub fn new() -> Self {
let mut registry = Handlebars::new();
registry
.register_template_string("module", MODULE_TEMPLATE)
.unwrap();
registry
.register_template_string("interface", INTERFACE_TEMPLATE)
.unwrap();
registry
.register_template_string("enum", ENUM_TEMPLATE)
.unwrap();
registry
.register_template_string("type", TYPE_TEMPLATE)
.unwrap();
Self { registry }
}
pub fn generate(&self, name: &str, schema: &Schema) -> Result<String, crate::Error> {
let context = SchemaContext::from_schema(name, schema);
match &schema.kind {
SchemaKind::Enum { values } => {
let ctx = EnumContext {
name: name.to_string(),
values: values.clone(),
};
Ok(self.registry.render("enum", &ctx)?)
}
SchemaKind::Object { .. } => Ok(self.registry.render("interface", &context)?),
SchemaKind::Union { .. } => {
let ts_type = schema_to_ts_type(schema, &HashMap::new());
let ctx = TypeContext {
name: name.to_string(),
ts_type,
};
Ok(self.registry.render("type", &ctx)?)
}
SchemaKind::Named { schema, .. } => self.generate(name, schema),
_ => {
let ts_type = schema_to_ts_type(schema, &HashMap::new());
let ctx = TypeContext {
name: name.to_string(),
ts_type,
};
Ok(self.registry.render("type", &ctx)?)
}
}
}
pub fn generate_module(&self, registry: &SchemaRegistry) -> Result<String, crate::Error> {
let mut rendered: Vec<String> = Vec::new();
for (name, schema) in registry.schemas() {
rendered.push(self.generate(name, schema)?);
}
let mut output = String::new();
output.push_str("// Auto-generated by typebox-rs. DO NOT EDIT.\n\n");
for code in rendered {
output.push_str(&code);
output.push('\n');
}
Ok(output)
}
}
impl Default for TypeScriptGenerator {
fn default() -> Self {
Self::new()
}
}
#[derive(Serialize)]
struct SchemaContext {
name: String,
description: Option<String>,
properties: Vec<PropertyContext>,
type_refs: HashMap<String, String>,
}
#[derive(Serialize)]
struct PropertyContext {
name: String,
ts_type: String,
optional: bool,
description: Option<String>,
}
#[derive(Serialize)]
struct EnumContext {
name: String,
values: Vec<String>,
}
#[derive(Serialize)]
struct TypeContext {
name: String,
ts_type: String,
}
impl SchemaContext {
fn from_schema(name: &str, schema: &Schema) -> Self {
let mut properties = Vec::new();
if let SchemaKind::Object {
properties: props,
required,
..
} = &schema.kind
{
for (prop_name, prop_schema) in props {
let is_optional = !required.contains(prop_name);
properties.push(PropertyContext {
name: prop_name.clone(),
ts_type: schema_to_ts_type(prop_schema, &HashMap::new()),
optional: is_optional,
description: None,
});
}
}
Self {
name: name.to_string(),
description: schema.description.clone(),
properties,
type_refs: HashMap::new(),
}
}
}
fn schema_to_ts_type(schema: &Schema, refs: &HashMap<String, String>) -> String {
match &schema.kind {
SchemaKind::Null => "null".to_string(),
SchemaKind::Bool => "boolean".to_string(),
SchemaKind::Int8 { .. }
| SchemaKind::Int16 { .. }
| SchemaKind::Int32 { .. }
| SchemaKind::Int64 { .. } => "number".to_string(),
SchemaKind::UInt8 { .. }
| SchemaKind::UInt16 { .. }
| SchemaKind::UInt32 { .. }
| SchemaKind::UInt64 { .. } => "number".to_string(),
SchemaKind::Float32 { .. } | SchemaKind::Float64 { .. } => "number".to_string(),
SchemaKind::String { .. } => "string".to_string(),
SchemaKind::Bytes { .. } => "Uint8Array".to_string(),
SchemaKind::Array { items, .. } => {
format!("Array<{}>", schema_to_ts_type(items, refs))
}
SchemaKind::Tuple { items } => {
let types: Vec<_> = items.iter().map(|s| schema_to_ts_type(s, refs)).collect();
format!("[{}]", types.join(", "))
}
SchemaKind::Object { .. } => "Record<string, unknown>".to_string(),
SchemaKind::Union { any_of } => {
if any_of.len() == 2 {
let is_optional = any_of.iter().any(|s| matches!(&s.kind, SchemaKind::Null));
if is_optional {
let non_null: Vec<_> = any_of
.iter()
.filter(|s| !matches!(&s.kind, SchemaKind::Null))
.collect();
if non_null.len() == 1 {
return format!("{} | null", schema_to_ts_type(non_null[0], refs));
}
}
}
let types: Vec<_> = any_of.iter().map(|s| schema_to_ts_type(s, refs)).collect();
types.join(" | ")
}
SchemaKind::Literal { value } => match value {
LiteralValue::String(s) => format!("'{}'", s),
LiteralValue::Number(n) => format!("{}", n),
LiteralValue::Float(f) => format!("{}", f),
LiteralValue::Boolean(b) => format!("{}", b),
LiteralValue::Null => "null".to_string(),
},
SchemaKind::Enum { .. } => "string".to_string(),
SchemaKind::Ref { reference } => {
let name = reference
.strip_prefix("#/definitions/")
.unwrap_or(reference);
refs.get(name).cloned().unwrap_or_else(|| name.to_string())
}
SchemaKind::Named { name, .. } => name.clone(),
SchemaKind::Function {
parameters,
returns,
} => {
let params: Vec<_> = parameters
.iter()
.map(|s| schema_to_ts_type(s, refs))
.collect();
let ret = schema_to_ts_type(returns, refs);
format!("({}) => {}", params.join(", "), ret)
}
SchemaKind::Void => "void".to_string(),
SchemaKind::Never => "never".to_string(),
SchemaKind::Any => "any".to_string(),
SchemaKind::Unknown => "unknown".to_string(),
SchemaKind::Undefined => "undefined".to_string(),
SchemaKind::Recursive { schema } => schema_to_ts_type(schema, refs),
SchemaKind::Intersect { all_of } => {
let types: Vec<_> = all_of.iter().map(|s| schema_to_ts_type(s, refs)).collect();
types.join(" & ")
}
}
}
const MODULE_TEMPLATE: &str = r#"{{preamble}}{{#each schemas}}
{{{this}}}
{{/each}}"#;
const INTERFACE_TEMPLATE: &str = r#"{{#if description}}/** {{description}} */
{{/if}}export interface {{name}} {
{{#each properties}}
{{#if description}}/** {{description}} */
{{/if}}{{name}}{{#if optional}}?{{/if}}: {{ts_type}};
{{/each}}
}
"#;
const ENUM_TEMPLATE: &str = r#"export type {{name}} = {{#each values}}'{{this}}'{{#unless @last}} | {{/unless}}{{/each}};
"#;
const TYPE_TEMPLATE: &str = r#"export type {{name}} = {{{ts_type}}};
"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::SchemaBuilder;
#[test]
fn test_generate_interface() {
let gen = TypeScriptGenerator::new();
let schema = SchemaBuilder::object()
.field("id", SchemaBuilder::int64())
.field("name", SchemaBuilder::string().build())
.optional_field("email", SchemaBuilder::string().build())
.build();
let output = gen.generate("Person", &schema).unwrap();
assert!(output.contains("export interface Person"));
assert!(output.contains("id: number"));
assert!(output.contains("name: string"));
assert!(output.contains("email?: string"));
}
#[test]
fn test_generate_enum() {
let gen = TypeScriptGenerator::new();
let schema = SchemaBuilder::enum_values(vec!["Red", "Green", "Blue"]);
let output = gen.generate("Color", &schema).unwrap();
assert!(output.contains("export type Color"));
assert!(output.contains("'Red'"));
assert!(output.contains("'Green'"));
assert!(output.contains("'Blue'"));
}
#[test]
fn test_generate_module() {
let gen = TypeScriptGenerator::new();
let mut registry = SchemaRegistry::new();
registry.register(
"Person",
SchemaBuilder::object()
.field("id", SchemaBuilder::int64())
.field("name", SchemaBuilder::string().build())
.build(),
);
let output = gen.generate_module(®istry).unwrap();
assert!(output.contains("// Auto-generated"));
assert!(output.contains("export interface Person"));
}
#[test]
fn test_generate_function_type() {
let gen = TypeScriptGenerator::new();
let schema = SchemaBuilder::function(
vec![SchemaBuilder::int64(), SchemaBuilder::string().build()],
SchemaBuilder::void(),
);
let output = gen.generate("Callback", &schema).unwrap();
assert!(output.contains("export type Callback"));
assert!(output.contains("=> void"));
}
}