commit 0740da72976e88a58166aa1bcb62a5913c779cc3
parent 4f6389d9ae96b7a525ed062576dedc939a92d097
Author: triesap <tyson@radroots.org>
Date: Sun, 28 Jun 2026 00:10:23 +0000
ts: use dto_bindgen module rendering
- Pin dto_bindgen crates to the structured TypeScript module commit.
- Move DTO type generation onto dto_bindgen_backend_ts module APIs.
- Remove the SDK-local DTO type renderer and raw import preludes.
- Regenerate checked-in DTO type outputs from the new renderer.
Diffstat:
5 files changed, 119 insertions(+), 1039 deletions(-)
diff --git a/tools/sdk_xtask_import/Cargo.toml b/tools/sdk_xtask_import/Cargo.toml
@@ -14,6 +14,7 @@ name = "radroots_sdk_xtask"
path = "src/main.rs"
[dependencies]
+dto_bindgen_backend_ts = { workspace = true }
dto_bindgen_core = { workspace = true }
radroots_core = { workspace = true, features = ["dto-bindgen"] }
radroots_events = { workspace = true, features = ["dto-bindgen"] }
diff --git a/tools/sdk_xtask_import/src/dto_render.rs b/tools/sdk_xtask_import/src/dto_render.rs
@@ -1,981 +0,0 @@
-use std::collections::{BTreeMap, BTreeSet};
-
-use dto_bindgen_core::{
- BackendId, Config, EnumDef, EnumRepr, FieldDef, IntRepr, LargeIntPolicy, Primitive, Registry,
- StructDef, TypeDef, TypeId, TypeRef, VariantDef, VariantShape,
-};
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DtoTypesModule {
- imports_ts: String,
- body_ts: String,
-}
-
-impl DtoTypesModule {
- pub fn new(imports_ts: impl Into<String>, body_ts: impl Into<String>) -> Self {
- Self {
- imports_ts: imports_ts.into(),
- body_ts: body_ts.into(),
- }
- }
-
- pub fn imports_ts(&self) -> Option<&str> {
- (!self.imports_ts.is_empty()).then_some(self.imports_ts.as_str())
- }
-
- pub fn body_ts(&self) -> &str {
- self.body_ts.as_str()
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DtoTypeImport {
- import_name: String,
- from: String,
-}
-
-impl DtoTypeImport {
- pub fn new(import_name: impl Into<String>, from: impl Into<String>) -> Self {
- Self {
- import_name: import_name.into(),
- from: from.into(),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct DtoRegistryRenderOptions {
- config: Config,
- external_imports: BTreeMap<TypeId, DtoTypeImport>,
- external_overrides: BTreeMap<String, DtoTypeImport>,
-}
-
-impl DtoRegistryRenderOptions {
- pub fn new(config: Config) -> Self {
- Self {
- config,
- external_imports: BTreeMap::new(),
- external_overrides: BTreeMap::new(),
- }
- }
-
- pub fn with_external_type(
- mut self,
- type_id: TypeId,
- import_name: impl Into<String>,
- from: impl Into<String>,
- ) -> Self {
- self.external_imports
- .insert(type_id, DtoTypeImport::new(import_name, from));
- self
- }
-
- pub fn with_external_override(
- mut self,
- target_type: impl Into<String>,
- import_name: impl Into<String>,
- from: impl Into<String>,
- ) -> Self {
- self.external_overrides
- .insert(target_type.into(), DtoTypeImport::new(import_name, from));
- self
- }
-}
-
-impl Default for DtoRegistryRenderOptions {
- fn default() -> Self {
- Self::new(Config::default())
- }
-}
-
-pub fn render_registry_types(
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
-) -> Result<DtoTypesModule, String> {
- let mut imports = BTreeMap::<String, BTreeSet<String>>::new();
- let mut declarations = Vec::new();
- let mut type_defs = registry
- .types_by_id
- .iter()
- .filter(|(type_id, _)| !options.external_imports.contains_key(type_id))
- .collect::<Vec<_>>();
-
- type_defs.sort_by(|(_, left), (_, right)| type_name(left).cmp(type_name(right)));
-
- for (type_id, type_def) in type_defs {
- declarations.push(render_type_def(
- *type_id,
- type_def,
- registry,
- options,
- &mut imports,
- )?);
- }
-
- Ok(DtoTypesModule::new(
- render_imports(&imports),
- declarations.join("\n\n"),
- ))
-}
-
-fn render_type_def(
- type_id: TypeId,
- type_def: &TypeDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- match type_def {
- TypeDef::Struct(def) => render_struct(type_id, def, registry, options, imports),
- TypeDef::Enum(def) => render_enum(type_id, def, registry, options, imports),
- }
-}
-
-fn render_struct(
- _type_id: TypeId,
- def: &StructDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- Ok(format!(
- "export type {}{} = {};",
- struct_type_name(def),
- render_generic_params(def.generics.iter().map(|param| param.name.as_str())),
- render_object_fields(&def.fields, registry, options, imports)?
- ))
-}
-
-fn render_enum(
- _type_id: TypeId,
- def: &EnumDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- match &def.repr {
- EnumRepr::External => render_external_enum(def, registry, options, imports),
- EnumRepr::Internal { tag } => {
- render_tagged_enum(def, tag, None, registry, options, imports)
- }
- EnumRepr::Adjacent { tag, content } => {
- render_tagged_enum(def, tag, Some(content.as_str()), registry, options, imports)
- }
- EnumRepr::Untagged => render_untagged_enum(def, registry, options, imports),
- }
-}
-
-fn render_untagged_enum(
- def: &EnumDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let variants = def
- .variants
- .iter()
- .map(|variant| render_untagged_variant(def, variant, registry, options, imports))
- .collect::<Result<Vec<_>, _>>()?;
- Ok(format!(
- "export type {} = {};",
- enum_type_name(def),
- render_union(variants)
- ))
-}
-
-fn render_untagged_variant(
- def: &EnumDef,
- variant: &VariantDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let rendered: Result<String, String> = match &variant.shape {
- VariantShape::Unit => {
- Err("untagged unit variants are unsupported for JSON DTO output".to_owned())
- }
- VariantShape::Newtype(ty) => render_type_ref(ty, None, registry, options, imports),
- VariantShape::Tuple(items) => {
- let rendered = items
- .iter()
- .map(|item| render_type_ref(item, None, registry, options, imports))
- .collect::<Result<Vec<_>, _>>()?;
- Ok(format!("[{}]", rendered.join(", ")))
- }
- VariantShape::Struct(fields) => render_object_fields(fields, registry, options, imports),
- };
-
- rendered.map_err(|error| {
- format!(
- "{error} while rendering untagged enum {}.{}",
- enum_type_name(def),
- variant.rust_name
- )
- })
-}
-
-fn render_external_enum(
- def: &EnumDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let variants = def
- .variants
- .iter()
- .map(|variant| render_external_variant(def, variant, registry, options, imports))
- .collect::<Result<Vec<_>, _>>()?;
- Ok(format!(
- "export type {} = {};",
- enum_type_name(def),
- render_union(variants)
- ))
-}
-
-fn render_external_variant(
- def: &EnumDef,
- variant: &VariantDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let rendered: Result<String, String> = match &variant.shape {
- VariantShape::Unit => Ok(quote_string(&variant.wire_name)),
- VariantShape::Newtype(ty) => Ok(format!(
- "{{ {}: {}, }}",
- render_property_name(&variant.wire_name),
- render_type_ref(ty, None, registry, options, imports)?
- )),
- VariantShape::Tuple(items) => {
- let rendered = items
- .iter()
- .map(|item| render_type_ref(item, None, registry, options, imports))
- .collect::<Result<Vec<_>, _>>()?;
- Ok(format!(
- "{{ {}: [{}], }}",
- render_property_name(&variant.wire_name),
- rendered.join(", ")
- ))
- }
- VariantShape::Struct(fields) => Ok(format!(
- "{{ {}: {}, }}",
- render_property_name(&variant.wire_name),
- render_object_fields(fields, registry, options, imports)?
- )),
- };
-
- rendered.map_err(|error| {
- format!(
- "{error} while rendering external enum {}.{}",
- enum_type_name(def),
- variant.rust_name
- )
- })
-}
-
-fn render_tagged_enum(
- def: &EnumDef,
- tag: &str,
- content: Option<&str>,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let variants = def
- .variants
- .iter()
- .map(|variant| {
- render_tagged_variant(def, variant, tag, content, registry, options, imports)
- })
- .collect::<Result<Vec<_>, _>>()?;
- Ok(format!(
- "export type {} = {};",
- enum_type_name(def),
- render_union(variants)
- ))
-}
-
-fn render_tagged_variant(
- def: &EnumDef,
- variant: &VariantDef,
- tag: &str,
- content: Option<&str>,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let mut fields = vec![format!(
- "{}: {}",
- render_property_name(tag),
- quote_string(&variant.wire_name)
- )];
-
- match (&variant.shape, content) {
- (VariantShape::Unit, _) => {}
- (VariantShape::Struct(variant_fields), Some(content)) => {
- fields.push(format!(
- "{}: {}",
- render_property_name(content),
- render_object_fields(variant_fields, registry, options, imports)?
- ));
- }
- (VariantShape::Struct(variant_fields), None) => {
- fields.extend(render_object_field_list(
- variant_fields,
- registry,
- options,
- imports,
- )?);
- }
- (VariantShape::Newtype(ty), Some(content)) => {
- fields.push(format!(
- "{}: {}",
- render_property_name(content),
- render_type_ref(ty, None, registry, options, imports)?
- ));
- }
- (VariantShape::Tuple(items), Some(content)) => {
- let rendered = items
- .iter()
- .map(|item| render_type_ref(item, None, registry, options, imports))
- .collect::<Result<Vec<_>, _>>()?;
- fields.push(format!(
- "{}: [{}]",
- render_property_name(content),
- rendered.join(", ")
- ));
- }
- (VariantShape::Newtype(_) | VariantShape::Tuple(_), None) => {
- return Err(format!(
- "unsupported internally tagged variant shape for {}.{}",
- enum_type_name(def),
- variant.rust_name
- ));
- }
- }
-
- Ok(format!("{{ {}, }}", fields.join(", ")))
-}
-
-fn render_object_fields(
- fields: &[FieldDef],
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let rendered = render_object_field_list(fields, registry, options, imports)?;
- if rendered.is_empty() {
- return Ok("{}".to_owned());
- }
- Ok(format!("{{ {}, }}", rendered.join(", ")))
-}
-
-fn render_object_field_list(
- fields: &[FieldDef],
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<Vec<String>, String> {
- fields
- .iter()
- .filter(|field| field.presence.is_serialized())
- .map(|field| render_object_field(field, registry, options, imports))
- .collect()
-}
-
-fn render_object_field(
- field: &FieldDef,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- let optional = if field.presence.required_on_deserialize {
- ""
- } else {
- "?"
- };
- let mut value = render_type_ref(&field.ty, field.int_repr, registry, options, imports)
- .map_err(|error| {
- format!(
- "{error} while rendering field {} at {}",
- field.target.typescript, field.source
- )
- })?;
- if field.presence.nullable {
- value = render_nullable(value);
- }
- Ok(format!(
- "{}{}: {}",
- render_property_name(&field.target.typescript),
- optional,
- value
- ))
-}
-
-fn render_type_ref(
- ty: &TypeRef,
- int_repr: Option<IntRepr>,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- match ty {
- TypeRef::Primitive(primitive) => render_primitive(*primitive, int_repr, options),
- TypeRef::String => Ok("string".to_owned()),
- TypeRef::Bytes(_) => Ok("Uint8Array".to_owned()),
- TypeRef::Option(inner) => Ok(render_nullable(render_type_ref(
- inner, int_repr, registry, options, imports,
- )?)),
- TypeRef::Vec(inner) => Ok(format!(
- "Array<{}>",
- render_type_ref(inner, int_repr, registry, options, imports)?
- )),
- TypeRef::Array { item, len } => {
- let item = render_type_ref(item, int_repr, registry, options, imports)?;
- Ok(format!("[{}]", vec![item; *len].join(", ")))
- }
- TypeRef::Map { key, value } => {
- if !matches!(key.as_ref(), TypeRef::String) {
- return Err("non-string map keys are unsupported".to_owned());
- }
- Ok(format!(
- "Record<string, {}>",
- render_type_ref(value, int_repr, registry, options, imports)?
- ))
- }
- TypeRef::Named(type_id) => render_named_type(*type_id, registry, options, imports),
- TypeRef::GenericParam(name) => Ok(name.clone()),
- TypeRef::Override(target) if target.backend == BackendId::TypeScript => {
- if let Some(import) = options.external_overrides.get(&target.target_type) {
- imports
- .entry(import.from.clone())
- .or_default()
- .insert(import.import_name.clone());
- return Ok(import.import_name.clone());
- }
- Ok(target.target_type.clone())
- }
- TypeRef::Override(_) => Err("target override is for a different backend".to_owned()),
- }
-}
-
-fn render_named_type(
- type_id: TypeId,
- registry: &Registry,
- options: &DtoRegistryRenderOptions,
- imports: &mut BTreeMap<String, BTreeSet<String>>,
-) -> Result<String, String> {
- if let Some(import) = options.external_imports.get(&type_id) {
- imports
- .entry(import.from.clone())
- .or_default()
- .insert(import.import_name.clone());
- return Ok(import.import_name.clone());
- }
-
- let type_def = registry
- .type_def(type_id)
- .ok_or_else(|| format!("missing named type reference {type_id}"))?;
- Ok(type_name(type_def).to_owned())
-}
-
-fn render_primitive(
- primitive: Primitive,
- int_repr: Option<IntRepr>,
- options: &DtoRegistryRenderOptions,
-) -> Result<String, String> {
- if primitive.requires_explicit_integer_policy() {
- return match int_repr {
- Some(IntRepr::JsonString) => Ok("string".to_owned()),
- Some(IntRepr::JsonNumber) => Ok("number".to_owned()),
- None => match options.config.numeric.large_int_policy {
- LargeIntPolicy::RequireExplicit => {
- Err("large integer field requires explicit numeric policy".to_owned())
- }
- LargeIntPolicy::JsonString => Ok("string".to_owned()),
- LargeIntPolicy::JsonNumber => Ok("number".to_owned()),
- },
- };
- }
-
- match primitive {
- Primitive::Bool => Ok("boolean".to_owned()),
- primitive if primitive.is_integer() || primitive.is_float() => Ok("number".to_owned()),
- _ => unreachable!("all primitive variants are covered by bool, integer, or float"),
- }
-}
-
-fn render_imports(imports: &BTreeMap<String, BTreeSet<String>>) -> String {
- let mut rendered = String::new();
- for (from, names) in imports {
- if names.len() == 1 {
- rendered.push_str("import type { ");
- rendered.push_str(names.iter().next().expect("single import name"));
- rendered.push_str(" } from ");
- rendered.push_str("e_string(from));
- rendered.push_str(";\n");
- } else {
- rendered.push_str("import type {\n");
- for name in names {
- rendered.push_str(" ");
- rendered.push_str(name);
- rendered.push_str(",\n");
- }
- rendered.push_str("} from ");
- rendered.push_str("e_string(from));
- rendered.push_str(";\n");
- }
- }
- if !rendered.is_empty() {
- rendered.push('\n');
- }
- rendered
-}
-
-fn render_nullable(value: String) -> String {
- if value.split(" | ").any(|part| part == "null") {
- value
- } else {
- format!("{value} | null")
- }
-}
-
-fn render_union(items: Vec<String>) -> String {
- if items.is_empty() {
- return "never".to_owned();
- }
-
- let mut seen = BTreeSet::new();
- let mut rendered = Vec::new();
- for item in items {
- if seen.insert(item.clone()) {
- rendered.push(item);
- }
- }
- rendered.join(" | ")
-}
-
-fn render_generic_params<'a>(params: impl Iterator<Item = &'a str>) -> String {
- let params = params.collect::<Vec<_>>();
- if params.is_empty() {
- String::new()
- } else {
- format!("<{}>", params.join(", "))
- }
-}
-
-fn type_name(type_def: &TypeDef) -> &str {
- match type_def {
- TypeDef::Struct(def) => struct_type_name(def),
- TypeDef::Enum(def) => enum_type_name(def),
- }
-}
-
-fn struct_type_name(def: &StructDef) -> &str {
- def.attrs.ts_name.as_deref().unwrap_or(&def.export_name)
-}
-
-fn enum_type_name(def: &EnumDef) -> &str {
- def.attrs.ts_name.as_deref().unwrap_or(&def.export_name)
-}
-
-fn render_property_name(value: &str) -> String {
- if is_identifier(value) {
- value.to_owned()
- } else {
- quote_string(value)
- }
-}
-
-fn is_identifier(value: &str) -> bool {
- let mut chars = value.chars();
- match chars.next() {
- Some(first) if first == '_' || first == '$' || first.is_ascii_alphabetic() => {}
- _ => return false,
- }
- chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
-}
-
-fn quote_string(value: &str) -> String {
- let mut escaped = String::with_capacity(value.len() + 2);
- escaped.push('"');
- for ch in value.chars() {
- match ch {
- '\\' => escaped.push_str("\\\\"),
- '"' => escaped.push_str("\\\""),
- '\n' => escaped.push_str("\\n"),
- '\r' => escaped.push_str("\\r"),
- '\t' => escaped.push_str("\\t"),
- ch => escaped.push(ch),
- }
- }
- escaped.push('"');
- escaped
-}
-
-#[cfg(test)]
-mod tests {
- use super::{DtoRegistryRenderOptions, render_registry_types};
- use dto_bindgen_core::{
- BackendId, EnumDef, EnumRepr, FieldDef, FieldPresence, GenericParam, IdentName, IntRepr,
- Primitive, Registry, RustTypeId, SourceSpan, StructDef, TargetFieldNames, TargetOverride,
- TypeDef, TypeRef, VariantDef, VariantShape, WireFieldNames,
- };
-
- fn span() -> SourceSpan {
- SourceSpan::new("src/dto.rs", 1, 1)
- }
-
- fn field(name: &str, target: &str, ty: TypeRef) -> FieldDef {
- FieldDef::new(
- IdentName::new(name),
- WireFieldNames::same(target),
- TargetFieldNames::new(target, name),
- ty,
- span(),
- )
- }
-
- #[test]
- fn renders_synthetic_registry_as_package_level_types() {
- let mut registry = Registry::new();
- let external_id = registry.register_type(
- RustTypeId::new("external", "external", "ExternalThing"),
- TypeDef::Struct(StructDef::new("ExternalThing", "ExternalThing", span())),
- );
- let envelope = TypeDef::Struct(StructDef {
- generics: vec![GenericParam::new("T")],
- ..StructDef::new("Envelope", "Envelope", span()).with_field(field(
- "value",
- "value",
- TypeRef::GenericParam("T".to_owned()),
- ))
- });
- registry.register_type(RustTypeId::new("sdk", "sdk", "Envelope"), envelope);
- let thing = TypeDef::Struct(
- StructDef::new("SyntheticThing", "SyntheticThing", span())
- .with_field(field("external", "external", TypeRef::Named(external_id)))
- .with_field(
- field(
- "maybe_count",
- "maybeCount",
- TypeRef::Primitive(Primitive::I64),
- )
- .with_presence(FieldPresence::optional_nullable())
- .with_int_repr(IntRepr::JsonString),
- )
- .with_field(field(
- "point",
- "point",
- TypeRef::array(TypeRef::Primitive(Primitive::F64), 2),
- ))
- .with_field(field(
- "envelope",
- "envelope",
- TypeRef::Override(TargetOverride::new(
- BackendId::TypeScript,
- "Envelope<ExternalThing>",
- )),
- )),
- );
- registry.register_type(RustTypeId::new("sdk", "sdk", "SyntheticThing"), thing);
- let mode = TypeDef::Enum(
- EnumDef::new("SyntheticMode", "SyntheticMode", EnumRepr::External, span())
- .with_variant(VariantDef::new(
- "Ready",
- "ready",
- VariantShape::Unit,
- span(),
- ))
- .with_variant(VariantDef::new("Done", "done", VariantShape::Unit, span())),
- );
- registry.register_type(RustTypeId::new("sdk", "sdk", "SyntheticMode"), mode);
- let event = TypeDef::Enum(
- EnumDef::new(
- "SyntheticEvent",
- "SyntheticEvent",
- EnumRepr::Adjacent {
- tag: "type".to_owned(),
- content: "payload".to_owned(),
- },
- span(),
- )
- .with_variant(VariantDef::new(
- "Created",
- "created",
- VariantShape::Struct(vec![field("id", "id", TypeRef::String)]),
- span(),
- ))
- .with_variant(VariantDef::new(
- "Archived",
- "archived",
- VariantShape::Struct(vec![
- field("reason", "reason", TypeRef::option(TypeRef::String))
- .with_presence(FieldPresence::optional_nullable_skip_if_none()),
- ]),
- span(),
- )),
- );
- registry.register_type(RustTypeId::new("sdk", "sdk", "SyntheticEvent"), event);
-
- let rendered = render_registry_types(
- ®istry,
- &DtoRegistryRenderOptions::default().with_external_type(
- external_id,
- "ExternalThing",
- "@radroots/external-bindings",
- ),
- )
- .expect("registry renders");
-
- assert_eq!(
- rendered.imports_ts(),
- Some("import type { ExternalThing } from \"@radroots/external-bindings\";\n\n")
- );
- assert_eq!(
- rendered.body_ts(),
- "export type Envelope<T> = { value: T, };\n\nexport type SyntheticEvent = { type: \"created\", payload: { id: string, }, } | { type: \"archived\", payload: { reason?: string | null, }, };\n\nexport type SyntheticMode = \"ready\" | \"done\";\n\nexport type SyntheticThing = { external: ExternalThing, maybeCount?: string | null, point: [number, number], envelope: Envelope<ExternalThing>, };"
- );
- }
-
- #[test]
- fn imports_typescript_overrides_when_configured() {
- let mut registry = Registry::new();
- registry.register_type(
- RustTypeId::new("sdk", "sdk", "SyntheticThing"),
- TypeDef::Struct(
- StructDef::new("SyntheticThing", "SyntheticThing", span()).with_field(field(
- "external",
- "external",
- TypeRef::Override(TargetOverride::new(BackendId::TypeScript, "ExternalAlias")),
- )),
- ),
- );
-
- let rendered = render_registry_types(
- ®istry,
- &DtoRegistryRenderOptions::default().with_external_override(
- "ExternalAlias",
- "ExternalAlias",
- "@radroots/external-bindings",
- ),
- )
- .expect("registry renders");
-
- assert_eq!(
- rendered.imports_ts(),
- Some("import type { ExternalAlias } from \"@radroots/external-bindings\";\n\n")
- );
- assert_eq!(
- rendered.body_ts(),
- "export type SyntheticThing = { external: ExternalAlias, };"
- );
- }
-
- #[test]
- fn renders_untagged_object_unions() {
- let mut registry = Registry::new();
- registry.register_type(
- RustTypeId::new("sdk", "sdk", "Query"),
- TypeDef::Enum(
- EnumDef::new("Query", "Query", EnumRepr::Untagged, span())
- .with_variant(VariantDef::new(
- "ById",
- "byId",
- VariantShape::Struct(vec![field("id", "id", TypeRef::String)]),
- span(),
- ))
- .with_variant(VariantDef::new(
- "BySlug",
- "bySlug",
- VariantShape::Struct(vec![field("slug", "slug", TypeRef::String)]),
- span(),
- )),
- ),
- );
-
- let rendered = render_registry_types(®istry, &DtoRegistryRenderOptions::default())
- .expect("registry renders");
-
- assert_eq!(
- rendered.body_ts(),
- "export type Query = { id: string, } | { slug: string, };"
- );
- }
-
- #[test]
- fn renders_untagged_newtype_aliases() {
- let mut registry = Registry::new();
- registry.register_type(
- RustTypeId::new("sdk", "sdk", "FindOneResolve"),
- TypeDef::Enum(
- EnumDef::new(
- "FindOneResolve",
- "FindOneResolve",
- EnumRepr::Untagged,
- span(),
- )
- .with_variant(VariantDef::new(
- "Alias",
- "alias",
- VariantShape::Newtype(TypeRef::Override(TargetOverride::new(
- BackendId::TypeScript,
- "IResult<Farm | null>",
- ))),
- span(),
- )),
- ),
- );
-
- let rendered = render_registry_types(®istry, &DtoRegistryRenderOptions::default())
- .expect("registry renders");
-
- assert_eq!(
- rendered.body_ts(),
- "export type FindOneResolve = IResult<Farm | null>;"
- );
- }
-
- #[test]
- fn rejects_untagged_unit_variants() {
- let mut registry = Registry::new();
- registry.register_type(
- RustTypeId::new("sdk", "sdk", "MaybeReady"),
- TypeDef::Enum(
- EnumDef::new("MaybeReady", "MaybeReady", EnumRepr::Untagged, span()).with_variant(
- VariantDef::new("Ready", "ready", VariantShape::Unit, span()),
- ),
- ),
- );
-
- let error = render_registry_types(®istry, &DtoRegistryRenderOptions::default())
- .expect_err("untagged unit variant blocks render");
-
- assert_eq!(
- error,
- "untagged unit variants are unsupported for JSON DTO output while rendering untagged enum MaybeReady.Ready"
- );
- }
-
- #[test]
- fn requires_explicit_large_integer_policy() {
- let mut registry = Registry::new();
- registry.register_type(
- RustTypeId::new("sdk", "sdk", "Counter"),
- TypeDef::Struct(
- StructDef::new("Counter", "Counter", span()).with_field(field(
- "value",
- "value",
- TypeRef::Primitive(Primitive::U64),
- )),
- ),
- );
-
- let error = render_registry_types(®istry, &DtoRegistryRenderOptions::default())
- .expect_err("missing policy blocks render");
-
- assert_eq!(
- error,
- "large integer field requires explicit numeric policy while rendering field value at src/dto.rs:1:1"
- );
- }
-
- #[test]
- fn propagates_integer_policy_through_transparent_containers_only() {
- let mut registry = Registry::new();
- let counter_id = registry.register_type(
- RustTypeId::new("sdk", "sdk", "Counter"),
- TypeDef::Struct(
- StructDef::new("Counter", "Counter", span()).with_field(
- field("value", "value", TypeRef::Primitive(Primitive::U64))
- .with_int_repr(IntRepr::JsonNumber),
- ),
- ),
- );
- registry.register_type(
- RustTypeId::new("sdk", "sdk", "TransparentCounters"),
- TypeDef::Struct(
- StructDef::new("TransparentCounters", "TransparentCounters", span())
- .with_field(
- field(
- "maybe_count",
- "maybeCount",
- TypeRef::option(TypeRef::Primitive(Primitive::U64)),
- )
- .with_presence(FieldPresence::optional_nullable())
- .with_int_repr(IntRepr::JsonString),
- )
- .with_field(
- field(
- "count_list",
- "countList",
- TypeRef::vec(TypeRef::Primitive(Primitive::U64)),
- )
- .with_int_repr(IntRepr::JsonString),
- )
- .with_field(
- field(
- "fixed_counts",
- "fixedCounts",
- TypeRef::array(TypeRef::Primitive(Primitive::U64), 2),
- )
- .with_int_repr(IntRepr::JsonString),
- )
- .with_field(
- field(
- "by_key",
- "byKey",
- TypeRef::Map {
- key: Box::new(TypeRef::String),
- value: Box::new(TypeRef::Primitive(Primitive::U64)),
- },
- )
- .with_int_repr(IntRepr::JsonString),
- )
- .with_field(
- field("named_counter", "namedCounter", TypeRef::Named(counter_id))
- .with_int_repr(IntRepr::JsonString),
- ),
- ),
- );
-
- let rendered = render_registry_types(®istry, &DtoRegistryRenderOptions::default())
- .expect("registry renders");
-
- assert_eq!(
- rendered.body_ts(),
- "export type Counter = { value: number, };\n\nexport type TransparentCounters = { maybeCount?: string | null, countList: Array<string>, fixedCounts: [string, string], byKey: Record<string, string>, namedCounter: Counter, };"
- );
- }
-
- #[test]
- fn renders_external_data_enums() {
- let mut registry = Registry::new();
- registry.register_type(
- RustTypeId::new("sdk", "sdk", "ParseError"),
- TypeDef::Enum(
- EnumDef::new("ParseError", "ParseError", EnumRepr::External, span())
- .with_variant(VariantDef::new(
- "InvalidKind",
- "InvalidKind",
- VariantShape::Newtype(TypeRef::Primitive(Primitive::U32)),
- span(),
- ))
- .with_variant(VariantDef::new(
- "InvalidUnit",
- "InvalidUnit",
- VariantShape::Unit,
- span(),
- )),
- ),
- );
-
- let rendered = render_registry_types(®istry, &DtoRegistryRenderOptions::default())
- .expect("registry renders");
-
- assert_eq!(
- rendered.body_ts(),
- "export type ParseError = { InvalidKind: number, } | \"InvalidUnit\";"
- );
- }
-}
diff --git a/tools/sdk_xtask_import/src/dto_roots.rs b/tools/sdk_xtask_import/src/dto_roots.rs
@@ -1,9 +1,11 @@
use std::collections::{BTreeMap, BTreeSet};
+use dto_bindgen_backend_ts::{
+ DtoRegistryRenderOptions, DtoTypesModule, TypeScriptDeclaration, TypeScriptModule,
+ TypeScriptType, render_registry_types,
+};
use dto_bindgen_core::{Registry, RootDescriptor, build_registry};
-use crate::dto_render::{DtoRegistryRenderOptions, DtoTypesModule, render_registry_types};
-
#[derive(Clone, Copy, Debug)]
pub struct DtoPackageRootSet {
pub package_key: &'static str,
@@ -182,12 +184,12 @@ pub fn core_types_module() -> Result<DtoTypesModule, String> {
let root_set = package_root_set("core").ok_or_else(|| "missing core DTO roots".to_owned())?;
let rendered =
render_registry_types(&root_set.registry(), &DtoRegistryRenderOptions::default())?;
- Ok(DtoTypesModule::new(
- rendered.imports_ts().unwrap_or_default(),
- format!(
- "export type RadrootsCoreCurrency = string;\n\nexport type RadrootsCoreDecimal = string;\n\n{}",
- rendered.body_ts()
- ),
+ Ok(with_type_aliases_sorted(
+ rendered,
+ [
+ type_alias("RadrootsCoreCurrency", TypeScriptType::String),
+ type_alias("RadrootsCoreDecimal", TypeScriptType::String),
+ ],
))
}
@@ -197,10 +199,7 @@ pub fn events_types_module() -> Result<DtoTypesModule, String> {
let registry = root_set.registry();
let options = core_import_options(®istry, DtoRegistryRenderOptions::default())?;
let rendered = render_registry_types(®istry, &options)?;
- Ok(DtoTypesModule::new(
- rendered.imports_ts().unwrap_or_default(),
- with_events_sdk_wrappers(rendered.body_ts()),
- ))
+ Ok(with_events_sdk_wrappers(rendered))
}
pub fn events_indexed_types_module() -> Result<DtoTypesModule, String> {
@@ -208,10 +207,7 @@ pub fn events_indexed_types_module() -> Result<DtoTypesModule, String> {
.ok_or_else(|| "missing events-indexed DTO roots".to_owned())?;
let rendered =
render_registry_types(&root_set.registry(), &DtoRegistryRenderOptions::default())?;
- Ok(DtoTypesModule::new(
- rendered.imports_ts().unwrap_or_default(),
- with_events_indexed_sdk_wrappers(rendered.body_ts()),
- ))
+ Ok(with_events_indexed_sdk_wrappers(rendered))
}
pub fn replica_db_schema_types_module() -> Result<DtoTypesModule, String> {
@@ -393,7 +389,7 @@ fn imported_type_inventory(imports_ts: &str) -> BTreeMap<String, BTreeSet<String
let line = line.trim();
if let Some(single) = line.strip_prefix("import type { ") {
if let Some((name, from)) = single.split_once(" } from ") {
- insert_import(&mut imports, from, name);
+ insert_import_names(&mut imports, from, name);
}
} else if line == "import type {" {
pending_names = Some(Vec::new());
@@ -413,6 +409,12 @@ fn imported_type_inventory(imports_ts: &str) -> BTreeMap<String, BTreeSet<String
imports
}
+fn insert_import_names(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, names: &str) {
+ for name in names.split(',') {
+ insert_import(imports, from, name);
+ }
+}
+
fn insert_import(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, name: &str) {
let from = from.trim_end_matches(';').trim_matches('"');
imports
@@ -448,18 +450,30 @@ const fn events_override(export_name: &'static str) -> DtoExternalOverride {
}
}
-fn with_events_sdk_wrappers(body: &str) -> String {
- let mut declarations = body
+fn with_events_sdk_wrappers(module: DtoTypesModule) -> DtoTypesModule {
+ let mut declarations = module
+ .body_ts()
.split("\n\n")
.filter(|declaration| !declaration.trim().is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
- declarations.push(
- "export type RadrootsListingProductTagKeys = readonly [\"key\", \"title\", \"category\", \"summary\", \"process\", \"lot\", \"location\", \"profile\", \"year\"];"
- .to_owned(),
- );
+ declarations.push(type_alias(
+ "RadrootsListingProductTagKeys",
+ TypeScriptType::readonly_tuple(
+ [
+ "key", "title", "category", "summary", "process", "lot", "location", "profile",
+ "year",
+ ]
+ .into_iter()
+ .map(TypeScriptType::literal_string)
+ .collect::<Vec<_>>(),
+ ),
+ ));
declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right)));
- declarations.join("\n\n")
+ DtoTypesModule::new(
+ module.imports_ts().unwrap_or_default(),
+ declarations.join("\n\n"),
+ )
}
fn declaration_name(declaration: &str) -> &str {
@@ -469,13 +483,17 @@ fn declaration_name(declaration: &str) -> &str {
.unwrap_or(declaration)
}
-fn with_events_indexed_sdk_wrappers(body: &str) -> String {
- let mut declarations = body
+fn with_events_indexed_sdk_wrappers(module: DtoTypesModule) -> DtoTypesModule {
+ let mut declarations = module
+ .body_ts()
.split("\n\n")
.filter(|declaration| !declaration.trim().is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
- declarations.push("export type RadrootsEventsIndexedShardId = string;".to_owned());
+ declarations.push(type_alias(
+ "RadrootsEventsIndexedShardId",
+ TypeScriptType::String,
+ ));
let order = [
"RadrootsEventsIndexedShardId",
"RadrootsEventsIndexedIdRange",
@@ -490,7 +508,36 @@ fn with_events_indexed_sdk_wrappers(body: &str) -> String {
.position(|name| *name == declaration_name(declaration))
.unwrap_or(order.len())
});
- declarations.join("\n\n")
+ DtoTypesModule::new(
+ module.imports_ts().unwrap_or_default(),
+ declarations.join("\n\n"),
+ )
+}
+
+fn with_type_aliases_sorted(
+ module: DtoTypesModule,
+ aliases: impl IntoIterator<Item = String>,
+) -> DtoTypesModule {
+ let mut declarations = module
+ .body_ts()
+ .split("\n\n")
+ .filter(|declaration| !declaration.trim().is_empty())
+ .map(str::to_owned)
+ .collect::<Vec<_>>();
+ declarations.extend(aliases);
+ declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right)));
+ DtoTypesModule::new(
+ module.imports_ts().unwrap_or_default(),
+ declarations.join("\n\n"),
+ )
+}
+
+fn type_alias(name: impl Into<String>, type_expr: TypeScriptType) -> String {
+ TypeScriptModule::new("types.ts")
+ .with_declaration(TypeScriptDeclaration::type_alias(name, type_expr))
+ .render_source()
+ .trim()
+ .to_owned()
}
#[cfg(test)]
@@ -506,7 +553,7 @@ mod tests {
type_exports, validate_external_override_target, validate_external_override_usage,
with_checked_external_type,
};
- use crate::dto_render::{DtoRegistryRenderOptions, DtoTypesModule};
+ use dto_bindgen_backend_ts::{DtoRegistryRenderOptions, DtoTypesModule};
const EVENTS_BINDINGS_TYPES_TS: &str =
include_str!("../../../packages/events-bindings/src/generated/types.ts");
@@ -836,12 +883,12 @@ mod tests {
#[test]
fn generated_import_inventory_parses_single_and_multiline_imports() {
let imports = imported_type_inventory(
- "import type { One } from \"@radroots/one\";\nimport type {\n Two,\n Three,\n} from \"@radroots/many\";\n\n",
+ "import type { One, Four } from \"@radroots/one\";\nimport type {\n Two,\n Three,\n} from \"@radroots/many\";\n\n",
);
assert_eq!(
imports.get("@radroots/one").expect("single import package"),
- &BTreeSet::from(["One".to_owned()])
+ &BTreeSet::from(["Four".to_owned(), "One".to_owned()])
);
assert_eq!(
imports.get("@radroots/many").expect("multi import package"),
diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs
@@ -3,8 +3,6 @@ mod contracts;
mod coverage;
mod coverage_policy;
#[allow(dead_code)]
-mod dto_render;
-#[allow(dead_code)]
mod dto_roots;
mod fs;
mod generate;
diff --git a/tools/sdk_xtask_import/src/output.rs b/tools/sdk_xtask_import/src/output.rs
@@ -1,5 +1,6 @@
+use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport, TypeScriptModule};
+
use crate::{
- dto_render::DtoTypesModule,
dto_roots,
manifest::manifest_file_name,
manifest::package_manifest,
@@ -10,7 +11,7 @@ use crate::{
pub struct PackageOutput {
pub spec: PackageSpec,
pub types_ts: Option<TsSource>,
- pub types_imports_ts: Option<&'static str>,
+ pub types_imports: Vec<TypeScriptImport>,
pub constants_ts: Option<TsSource>,
pub kinds_ts: Option<TsSource>,
}
@@ -45,7 +46,10 @@ impl PackageOutput {
pub fn files(&self) -> Vec<GeneratedFile> {
let mut files = Vec::new();
if let Some(types_ts) = &self.types_ts {
- let imports = combined_imports(self.types_imports_ts, types_ts.imports());
+ let imports = combined_imports(
+ structured_imports_ts(&self.types_imports).as_deref(),
+ types_ts.imports(),
+ );
files.push(GeneratedFile {
relative_path: format!("src/generated/{}", generated_types_file()),
contents: render_ts(types_ts, imports.as_deref()),
@@ -80,14 +84,14 @@ pub fn package_outputs() -> Result<Vec<PackageOutput>, String> {
PackageOutput {
spec: spec_by_key("core"),
types_ts: Some(TsSource::DtoRegistry(dto_roots::core_types_module()?)),
- types_imports_ts: None,
+ types_imports: Vec::new(),
constants_ts: None,
kinds_ts: None,
},
PackageOutput {
spec: spec_by_key("events"),
types_ts: Some(TsSource::DtoRegistry(dto_roots::events_types_module()?)),
- types_imports_ts: None,
+ types_imports: Vec::new(),
constants_ts: Some(TsSource::Raw(radroots_events_bindings::constants_module())),
kinds_ts: Some(TsSource::Raw(radroots_events_bindings::kinds_module())),
},
@@ -96,14 +100,14 @@ pub fn package_outputs() -> Result<Vec<PackageOutput>, String> {
types_ts: Some(TsSource::DtoRegistry(
dto_roots::events_indexed_types_module()?,
)),
- types_imports_ts: None,
+ types_imports: Vec::new(),
constants_ts: None,
kinds_ts: None,
},
PackageOutput {
spec: spec_by_key("identity"),
types_ts: None,
- types_imports_ts: None,
+ types_imports: Vec::new(),
constants_ts: Some(TsSource::Raw(radroots_identity_bindings::constants_module())),
kinds_ts: None,
},
@@ -112,21 +116,24 @@ pub fn package_outputs() -> Result<Vec<PackageOutput>, String> {
types_ts: Some(TsSource::DtoRegistry(
dto_roots::replica_db_schema_types_module()?,
)),
- types_imports_ts: Some(REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS),
+ types_imports: vec![TypeScriptImport::type_only(
+ ["IResult", "IResultList", "IResultPass"],
+ "@radroots/types-bindings",
+ )],
constants_ts: None,
kinds_ts: None,
},
PackageOutput {
spec: spec_by_key("trade"),
types_ts: Some(TsSource::DtoRegistry(dto_roots::trade_types_module()?)),
- types_imports_ts: None,
+ types_imports: Vec::new(),
constants_ts: None,
kinds_ts: None,
},
PackageOutput {
spec: spec_by_key("types"),
types_ts: Some(TsSource::DtoRegistry(dto_roots::types_types_module()?)),
- types_imports_ts: None,
+ types_imports: Vec::new(),
constants_ts: None,
kinds_ts: None,
},
@@ -160,13 +167,20 @@ fn combined_imports(first: Option<&str>, second: Option<&str>) -> Option<String>
}
}
-const REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS: &str = r#"import type {
- IResult,
- IResultList,
- IResultPass,
-} from "@radroots/types-bindings";
-
-"#;
+fn structured_imports_ts(imports: &[TypeScriptImport]) -> Option<String> {
+ if imports.is_empty() {
+ return None;
+ }
+ Some(
+ imports
+ .iter()
+ .cloned()
+ .fold(TypeScriptModule::new("types.ts"), |module, import| {
+ module.with_import(import)
+ })
+ .render_source(),
+ )
+}
fn render_manifest(spec: PackageSpec) -> String {
let mut value = package_manifest(spec);
@@ -197,7 +211,8 @@ fn render_index(output: &PackageOutput) -> String {
#[cfg(test)]
mod tests {
use super::{PackageOutput, TsSource, package_outputs, render_ts};
- use crate::{dto_render::DtoTypesModule, package_matrix::package_specs};
+ use crate::package_matrix::package_specs;
+ use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport};
const TRADE_BINDINGS_TYPES_TS: &str =
include_str!("../../../packages/trade-bindings/src/generated/types.ts");
@@ -256,7 +271,10 @@ mod tests {
"import type { ExternalThing } from \"@radroots/external-bindings\";\n\n",
"export type SyntheticThing = { external: ExternalThing, };",
))),
- types_imports_ts: Some("import type { LocalPrelude } from \"@radroots/local\";\n\n"),
+ types_imports: vec![TypeScriptImport::type_only(
+ ["LocalPrelude"],
+ "@radroots/local",
+ )],
constants_ts: None,
kinds_ts: None,
};
@@ -276,7 +294,7 @@ mod tests {
assert_eq!(
types.contents,
- "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\n\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n"
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n"
);
assert!(manifest.contents.contains("\"generated\": true"));
assert_eq!(index.contents, "export * from \"./generated/types.js\";\n");
@@ -291,7 +309,7 @@ mod tests {
.expect("trade output");
assert!(matches!(output.types_ts, Some(TsSource::DtoRegistry(_))));
- assert!(output.types_imports_ts.is_none());
+ assert!(output.types_imports.is_empty());
let types = output
.files()
@@ -311,10 +329,7 @@ mod tests {
.expect("replica_db_schema output");
assert!(matches!(output.types_ts, Some(TsSource::DtoRegistry(_))));
- assert_eq!(
- output.types_imports_ts,
- Some(super::REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS)
- );
+ assert_eq!(output.types_imports.len(), 1);
let types = output
.files()