use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToUpperCamelCase};
use indexmap::IndexMap;
use crate::error::{Error, Result};
use crate::generator::json_schema::typescript as typescript_json;
use crate::generator::proto::typescript as typescript_proto;
use crate::generator::proto::typescript::{
model_typescript_interface_ref, model_typescript_type_id, typescript_replacement_type_name,
};
use crate::generator::render_request_plan;
use crate::generator::{ExternalModelBackend, GeneratedFiles, GenerationMode, TsDateTimeTypes};
use crate::language::Language;
use crate::planning::{
PlannedFamily, PlannedOperationResourceFieldBinding, PlannedOperationResourceReturn,
PlannedProtoType, PlannedProtoTypeInfo, PlannedRecordType, PlannedResource,
PlannedResourceMethod, PlannedResourceMethodBindingSpec, PlannedResourceMethodResultKind,
PlannedSpec, PlannedType, PlannedWireFieldBinding, operation_input_model,
operation_output_direct_result,
};
use crate::planning::{RequestPlan, RequestPlanSource, ResolvedResourceBindingSource};
use crate::spec::{ApiSpecBranch, ApiSpecNode};
use crate::spec::{
EnumSpec, ExternalTypeSpec, FlagsSpec, FunctionArgsSpec, FunctionFieldSpec, FunctionResultSpec,
IntSpec, LanguageImportSpec, LanguageImportStyle, LanguageStringSpec, ModulePath,
OperationSpec, RecordFieldSpec, RecordFieldVisibility, RecordSpec, SupportFragmentSpec,
TypeReplacementSpec, TypeSpec, VariantSpec,
};
pub(in crate::generator) const GENERATED_HEADER: &str = concat!(
"// Generated by nexgen v",
env!("CARGO_PKG_VERSION"),
". DO NOT EDIT!"
);
const TYPESCRIPT_FORMAT_LINE_LENGTH: usize = 88;
const EXPERIMENTAL_WARNING: &str = "This API is experimental and subject to change.";
const WIRE_VALUE_EXPR: &str = "{wire}";
type PlannedOperation = OperationSpec<PlannedFamily>;
type PlannedFlags = FlagsSpec;
type PlannedVariant = VariantSpec<PlannedFamily>;
#[derive(Debug, Default)]
pub(in crate::generator) struct RenderedExternalModelFragments {
pub(in crate::generator) imports: String,
pub(in crate::generator) body: String,
pub(in crate::generator) type_exported_names: BTreeSet<String>,
pub(in crate::generator) value_exported_names: BTreeSet<String>,
}
impl RenderedExternalModelFragments {
fn extend(&mut self, other: Self) {
if !other.imports.is_empty() {
if !self.imports.is_empty() {
self.imports.push('\n');
}
self.imports.push_str(&other.imports);
}
if !other.body.is_empty() {
if !self.body.is_empty() {
self.body.push_str("\n\n");
}
self.body.push_str(&other.body);
}
self.type_exported_names.extend(other.type_exported_names);
self.value_exported_names.extend(other.value_exported_names);
}
}
pub(crate) fn render_tree_support_files(
branch: &ApiSpecBranch<PlannedFamily>,
) -> BTreeMap<PathBuf, String> {
if !branch_has_json_models(branch) {
return BTreeMap::new();
}
BTreeMap::from([(
PathBuf::from("definitions.ts"),
typescript_json::render_support_file(),
)])
}
fn generate_tree(
branch: &ApiSpecBranch<PlannedFamily>,
support: &crate::SupportFiles,
mode: GenerationMode,
ts_date_time_types: TsDateTimeTypes,
) -> Result<GeneratedFiles> {
let mut files = BTreeMap::new();
let mut warnings = Vec::new();
let tree_support_files = render_tree_support_files(branch);
let has_json_runtime_module = mode != GenerationMode::NativeApi
&& tree_support_files.contains_key(&PathBuf::from("definitions.ts"));
insert_branch_index_file(&mut files, branch, has_json_runtime_module)?;
insert_files(&mut files, tree_support_files)?;
for node in branch.children.values() {
generate_tree_node(
node,
support,
mode,
ts_date_time_types,
&mut files,
&mut warnings,
)?;
}
Ok(GeneratedFiles {
layout: crate::generator::GeneratedOutputLayout::Directory,
files,
warnings,
})
}
fn generate_tree_node(
node: &ApiSpecNode<PlannedFamily>,
support: &crate::SupportFiles,
mode: GenerationMode,
ts_date_time_types: TsDateTimeTypes,
files: &mut BTreeMap<PathBuf, String>,
warnings: &mut Vec<String>,
) -> Result<()> {
match node {
ApiSpecNode::Leaf(leaf) => {
let support_fragments = support_fragments_for_plan(&leaf.spec, support);
let generated =
generate_leaf(&leaf.spec, &support_fragments, mode, ts_date_time_types)?;
warnings.extend(generated.warnings);
let prefix = leaf.module_path.to_path_buf();
for (path, mut contents) in generated.files {
if path == PathBuf::from("index.ts")
&& !typescript_module_has_import_or_export(&contents)
{
contents.push_str("export {};\n");
}
insert_generated_file(files, prefix.join(path), contents)?;
}
Ok(())
}
ApiSpecNode::Branch(branch) => {
insert_branch_index_file(files, branch, false)?;
for node in branch.children.values() {
generate_tree_node(node, support, mode, ts_date_time_types, files, warnings)?;
}
Ok(())
}
}
}
fn insert_branch_index_file(
files: &mut BTreeMap<PathBuf, String>,
branch: &ApiSpecBranch<PlannedFamily>,
has_json_runtime_module: bool,
) -> Result<()> {
let mut path = branch.module_path.to_path_buf();
path.push("index.ts");
let mut contents = String::from(GENERATED_HEADER);
contents.push_str("\n\n");
for name in branch.children.keys() {
contents.push_str("export * from './");
contents.push_str(name);
contents.push_str("';\n");
}
if has_json_runtime_module {
contents.push_str("export { ValidationError } from './definitions';\n");
contents.push_str("export type { Violation } from './definitions';\n");
}
insert_generated_file(files, path, contents)
}
fn insert_files(
files: &mut BTreeMap<PathBuf, String>,
generated: BTreeMap<PathBuf, String>,
) -> Result<()> {
for (path, contents) in generated {
insert_generated_file(files, path, contents)?;
}
Ok(())
}
fn insert_generated_file(
files: &mut BTreeMap<PathBuf, String>,
path: PathBuf,
contents: String,
) -> Result<()> {
if files.insert(path.clone(), contents).is_some() {
return Err(Error::GeneratedFileConflict { path });
}
Ok(())
}
fn typescript_module_has_import_or_export(contents: &str) -> bool {
contents.lines().any(|line| {
let line = line.trim_start();
line.starts_with("import ") || line.starts_with("export ")
})
}
fn support_fragments_for_plan(
plan: &PlannedSpec,
support: &crate::SupportFiles,
) -> Vec<SupportFragmentSpec> {
if support.fragments.is_empty() {
plan.support
.fragments_for_language(Language::TypeScript)
.to_vec()
} else {
support.fragments.clone()
}
}
fn branch_has_json_models(branch: &ApiSpecBranch<PlannedFamily>) -> bool {
branch.children.values().any(|node| match node {
ApiSpecNode::Leaf(leaf) => leaf
.spec
.external_types()
.map(|(_, binding)| binding)
.any(|binding| matches!(binding.external_type, ExternalTypeSpec::Json(_))),
ApiSpecNode::Branch(branch) => branch_has_json_models(branch),
})
}
#[derive(Debug, Default)]
struct TypeScriptExternalModels {
proto: typescript_proto::ModelBackend,
json: typescript_json::ModelBackend,
}
impl TypeScriptExternalModels {
fn new(api_plan: &PlannedSpec, ts_date_time_types: TsDateTimeTypes) -> Result<Self> {
let mut this = Self::default();
this.json.ts_date_time_types = ts_date_time_types;
this.prepare(api_plan)?;
Ok(this)
}
fn render_model_wire_functions(
&self,
output: &mut String,
model: &RenderedModel,
planned_record: &RecordSpec<PlannedFamily>,
) -> bool {
self.proto
.render_model_wire_functions(output, model, planned_record)
}
fn render_support_files(&self) -> Result<BTreeMap<PathBuf, String>> {
let mut files = BTreeMap::new();
files.extend(self.json.render_support_files()?);
Ok(files)
}
fn transfer_type_converter(&self, model_type: &PlannedType) -> Option<String> {
match model_type {
PlannedType::External(ExternalTypeSpec::Json(json_type)) => {
Some(self.json.transfer_type_converter(json_type))
}
_ => None,
}
}
}
impl ExternalModelBackend for TypeScriptExternalModels {
type ModelFragments = RenderedExternalModelFragments;
type WireConversion = WireValueConversion;
fn prepare(&mut self, api_plan: &PlannedSpec) -> Result<()> {
self.proto.prepare(api_plan)?;
self.json.prepare(api_plan)
}
fn render_models(&self) -> Result<RenderedExternalModelFragments> {
let mut fragments = RenderedExternalModelFragments::default();
fragments.extend(self.proto.render_models()?);
fragments.extend(self.json.render_models()?);
Ok(fragments)
}
fn model_type_annotation(&self, model_type: &PlannedType) -> Option<String> {
match model_type {
PlannedType::External(ExternalTypeSpec::Proto(_)) => {
self.proto.model_type_annotation(model_type)
}
PlannedType::External(ExternalTypeSpec::Json(json_type)) => {
self.json.model_type_annotation(json_type)
}
_ => None,
}
}
fn wire_type_identifier(&self, model_type: &PlannedType) -> Option<String> {
match model_type {
PlannedType::External(ExternalTypeSpec::Proto(_)) => {
self.proto.wire_type_identifier(model_type)
}
PlannedType::External(ExternalTypeSpec::Json(json_type)) => {
self.json.wire_type_identifier(json_type)
}
_ => None,
}
}
fn wire_conversion(
&self,
model_type: &PlannedType,
planned_record: Option<&RecordSpec<PlannedFamily>>,
) -> Option<WireValueConversion> {
match model_type {
PlannedType::External(ExternalTypeSpec::Proto(_)) => {
self.proto.wire_conversion(model_type, planned_record)
}
PlannedType::External(ExternalTypeSpec::Json(json_type)) => {
self.json.wire_conversion(json_type, planned_record)
}
PlannedType::Record(_) => self.proto.wire_conversion(model_type, planned_record),
_ => None,
}
}
}
struct ApiPlanner<'a> {
api_plan: &'a PlannedSpec,
external_models: TypeScriptExternalModels,
enums: IndexMap<String, RenderedEnum>,
flags: IndexMap<String, RenderedFlags>,
variants: IndexMap<String, RenderedVariant>,
models: IndexMap<String, RenderedModel>,
}
impl<'a> ApiPlanner<'a> {
fn new(api_plan: &'a PlannedSpec, ts_date_time_types: TsDateTimeTypes) -> Result<Self> {
Ok(Self {
api_plan,
external_models: TypeScriptExternalModels::new(api_plan, ts_date_time_types)?,
enums: IndexMap::new(),
flags: IndexMap::new(),
variants: IndexMap::new(),
models: IndexMap::new(),
})
}
fn render_external_models(&self) -> Result<RenderedExternalModelFragments> {
self.external_models.render_models()
}
fn ensure_resource_field_types(&mut self, resource: &PlannedResource) {
for field in &resource.fields {
self.ensure_resource_field_type(&field.kind);
}
for method in &resource.methods {
for param in &method.params {
self.ensure_resource_field_type(¶m.kind);
}
}
}
fn ensure_resource_field_type(&mut self, kind: &PlannedType) {
match kind {
PlannedType::List(value) => {
self.resolve_planned_value_type(value);
}
PlannedType::Map(key, value) => {
self.resolve_planned_value_type(key);
self.resolve_planned_value_type(value);
}
value => {
self.resolve_planned_value_type(value);
}
}
}
fn resolve_operation<'operation>(
&mut self,
operation: &'operation PlannedOperation,
) -> Result<RenderedOperation<'operation>> {
let input = operation_input_model(operation);
let output_transform = operation.output_transform.as_ref();
let output_resource_return = operation.data.output_resource_return.clone();
let (output_operation_annotation, output_type_id, output_annotation_default) =
match operation.output_type() {
Some(
output @ (PlannedType::External(ExternalTypeSpec::Proto(
PlannedProtoType::Message(_),
))
| PlannedType::External(ExternalTypeSpec::Json(_))
| PlannedType::Record(_)),
) => {
if output_transform.is_none()
&& output_resource_return.is_none()
&& !operation.output_direct_result()
&& matches!(output, PlannedType::Record(_))
{
let _output_conversion = self.resolve_message_value_conversion(output);
}
(
self.operation_type_annotation(output),
self.operation_wire_type_identifier(output),
self.resolve_output_annotation(output),
)
}
Some(PlannedType::Resource(resource)) => {
if let Some(output) = &resource.wire_type {
let output = PlannedType::External(output.clone());
(
self.operation_type_annotation(&output),
self.operation_wire_type_identifier(&output),
self.resolve_output_annotation(&output),
)
} else {
(
resource.type_name.clone(),
resource.type_name.clone(),
resource.type_name.clone(),
)
}
}
None => ("void".to_string(), "void".to_string(), "void".to_string()),
Some(_) => {
panic!("planned operation output should be proto, record, resource, or none")
}
};
let input_full_name = input.and_then(|input| match input {
PlannedType::Record(record) => Some(record.full_name.as_str()),
_ => None,
});
let rendered_input = input.map(|input| {
let input_conversion = self.resolve_message_value_conversion(input);
let input_model =
input_full_name.and_then(|input_full_name| self.models.get(input_full_name));
let model_name = self.locally_defined_model_name(input);
let type_parameters = input_model
.map(|model| {
model
.type_parameters
.iter()
.filter(|type_parameter| type_parameter.infer_from_operation_request)
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let annotation = {
if input_conversion.uses_rendered_model_annotation() {
generic_model_annotation(&input_conversion.annotation, &type_parameters)
} else {
input_conversion.annotation.clone()
}
};
RenderedOperationInput {
operation_annotation: typescript_erased_model_annotation(
&self.operation_type_annotation(input),
&type_parameters,
),
type_id: self.operation_wire_type_identifier(input),
model_name,
type_parameters,
annotation,
api_omitted_fields: input_full_name
.and_then(|name| self.api_plan.record(name))
.map(|record| {
record
.fields
.values()
.filter(|field| field.visibility == RecordFieldVisibility::ApiOmitted)
.map(|field| field.name.clone())
.collect()
})
.unwrap_or_default(),
to_wire_expr: input_conversion.to_wire_expr("request"),
transfer_type_converter: self.external_models.transfer_type_converter(input),
}
});
let output_annotation_default = match operation.output_type() {
Some(PlannedType::Record(record)) => typescript_operation_output_annotation(
&output_annotation_default,
&self
.api_plan
.record_type_parameters(&record.full_name, Language::TypeScript),
input,
self.api_plan,
),
_ => output_annotation_default,
};
let service_output_operation_annotation = match operation.output_type() {
Some(PlannedType::Record(record)) => typescript_erased_record_annotation(
&output_operation_annotation,
&self
.api_plan
.record_type_parameters(&record.full_name, Language::TypeScript),
),
_ => output_operation_annotation.clone(),
};
let output_operation_annotation = match operation.output_type() {
Some(PlannedType::Record(record)) => typescript_operation_output_annotation(
&output_operation_annotation,
&self
.api_plan
.record_type_parameters(&record.full_name, Language::TypeScript),
input,
self.api_plan,
),
_ => output_operation_annotation,
};
Ok(RenderedOperation {
name: operation.name.as_str(),
wire_name: operation.wire_name.as_str(),
attr_name: operation
.code_name
.for_language(Language::TypeScript)
.map(str::to_string)
.unwrap_or_else(|| typescript_ident(&operation.name.to_lower_camel_case())),
experimental: operation.experimental,
deprecated: operation.deprecated,
doc: operation
.doc
.for_language(Language::TypeScript)
.map(str::to_string),
return_doc: operation
.return_doc
.for_language(Language::TypeScript)
.map(str::to_string),
output_operation_annotation,
service_output_operation_annotation,
output_type_id,
input: rendered_input,
output_annotation: output_transform
.and_then(|transform| {
transform
.type_name
.for_language(Language::TypeScript)
.map(str::to_string)
})
.or_else(|| {
output_resource_return
.as_ref()
.map(|resource| resource.resource_type_name.clone())
})
.unwrap_or(output_annotation_default),
output_transform_expr: output_transform.and_then(|transform| {
transform
.transform
.for_language(Language::TypeScript)
.map(str::to_string)
}),
output_resource_return,
output_direct_result: operation.output_direct_result(),
output_model_name: operation
.output_type()
.and_then(|output| self.locally_defined_model_name(output)),
output_transfer_type_converter: operation
.output_type()
.and_then(|output| self.external_models.transfer_type_converter(output)),
})
}
fn locally_defined_model_name(&self, model_type: &PlannedType) -> Option<String> {
match model_type {
PlannedType::Record(record) => self
.models
.get(record.full_name.as_str())
.map(|model| model.name.clone()),
PlannedType::External(ExternalTypeSpec::Json(json_type))
if json_type
.module_path
.as_ref()
.map(|module_path| *module_path == self.api_plan.module_path)
.unwrap_or(true) =>
{
self.external_models.model_type_annotation(model_type)
}
_ => None,
}
}
fn operation_type_annotation(&self, model_type: &PlannedType) -> String {
self.external_models
.model_type_annotation(model_type)
.or_else(|| model_typescript_interface_ref(model_type, self.api_plan))
.expect("operation type ref should be model-shaped")
}
fn operation_wire_type_identifier(&self, model_type: &PlannedType) -> String {
self.external_models
.wire_type_identifier(model_type)
.or_else(|| model_typescript_type_id(model_type, self.api_plan))
.expect("operation type id should be model-shaped")
}
fn resolve_output_annotation(&mut self, model_type: &PlannedType) -> String {
match model_type {
PlannedType::External(_) => self
.external_models
.model_type_annotation(model_type)
.expect("external operation output annotation should be model-shaped"),
PlannedType::Record(_) => {
let conversion = self.resolve_message_value_conversion(model_type);
conversion.annotation
}
_ => panic!("operation output annotation should be model-shaped"),
}
}
fn resolve_message_value_conversion(
&mut self,
model_type: &PlannedType,
) -> WireValueConversion {
if let Some(conversion) = self.external_models.wire_conversion(model_type, None) {
return conversion;
}
self.ensure_rendered_model(model_type);
let PlannedType::Record(record) = model_type else {
panic!("message conversion should be a record");
};
let full_name = record.full_name.as_str();
let rendered_model = self
.models
.get(full_name)
.expect("planned model should be rendered");
let planned_model = self
.api_plan
.record(full_name)
.unwrap_or_else(|| panic!("planned model should exist for {full_name}"));
let message = if let Some(conversion) = self
.external_models
.wire_conversion(model_type, Some(planned_model))
{
conversion
} else if matches!(model_type, PlannedType::Record(_)) {
WireValueConversion {
annotation: rendered_model.name.clone(),
from_wire: "{wire}".to_string(),
to_wire: "{value}".to_string(),
function_name_to_wire: None,
wire_function_names: None,
uses_rendered_model_annotation: true,
}
} else {
panic!("message conversion should be model-shaped")
};
message
}
fn ensure_rendered_model(&mut self, model_type: &PlannedType) {
let PlannedType::Record(record) = model_type else {
panic!("rendered model should be a record");
};
let full_name = record.full_name.as_str();
let planned_model = self
.api_plan
.record(full_name)
.unwrap_or_else(|| panic!("planned model should exist for {full_name}"))
.clone();
if self.models.contains_key(full_name) {
return;
}
let wire_conversion = self
.external_models
.wire_conversion(model_type, Some(&planned_model));
let wire_function_names = wire_conversion
.as_ref()
.and_then(|conversion| conversion.wire_function_names.clone());
self.models.insert(
full_name.to_string(),
RenderedModel {
full_name: planned_model.full_name.clone(),
name: planned_model.name.clone(),
wire_function_names,
experimental: planned_model.experimental,
type_parameters: model_type_parameters(&planned_model, self.api_plan),
fields: Vec::new(),
sourced_fields: Vec::new(),
functions: Vec::new(),
with_arguments: Vec::new(),
},
);
let fields = planned_model
.model_fields()
.map(|(field_name, field)| self.build_field(&planned_model, field_name, field))
.collect();
let sourced_fields = planned_model
.sourced_fields()
.map(|(field_name, field, source_expr)| {
self.build_sourced_field(field_name, field, source_expr)
})
.collect();
self.models
.get_mut(full_name)
.expect("model should be inserted before recursive field resolution")
.fields = fields;
self.models
.get_mut(full_name)
.expect("model should be inserted before recursive field resolution")
.sourced_fields = sourced_fields;
self.models
.get_mut(full_name)
.expect("model should be inserted before recursive field resolution")
.functions = Some(&planned_model)
.map(|model| {
model
.functions()
.filter(|(_, function)| typescript_function_type_descriptor(function).is_none())
.map(|(field_name, function)| RenderedFunctionField {
callable_field_name: model
.field_name_override(field_name)
.map(typescript_generated_field_name)
.unwrap_or_else(|| typescript_ident(&field_name.to_lower_camel_case())),
args_field_name: model
.field_name_override(&function.args_field)
.map(typescript_generated_field_name)
.unwrap_or_else(|| {
typescript_ident(&function.args_field.to_lower_camel_case())
}),
primary: function.primary,
args: typescript_function_args(&function.args),
type_parameter_name: function_type_parameter_name(
model.field_name_override(field_name).unwrap_or(field_name),
),
alternate_annotation: function
.alternate_type
.as_ref()
.map(typescript_authored_type_annotation),
})
.collect()
})
.unwrap_or_default();
self.models
.get_mut(full_name)
.expect("model should be inserted before recursive field resolution")
.with_arguments = Some(&planned_model)
.map(|model| {
model
.functions()
.filter(|(_, function)| typescript_function_type_descriptor(function).is_some())
.map(|(field_name, function)| RenderedWithArgumentsField {
value_field_name: model
.field_name_override(field_name)
.map(typescript_generated_field_name)
.unwrap_or_else(|| typescript_ident(&field_name.to_lower_camel_case())),
args_field_name: model
.field_name_override(&function.args_field)
.map(typescript_generated_field_name)
.unwrap_or_else(|| {
typescript_ident(&function.args_field.to_lower_camel_case())
}),
type_parameter_name: {
let generated_field_name =
model.field_name_override(field_name).unwrap_or(field_name);
with_arguments_type_parameter_name(generated_field_name)
},
args_type_parameter_name: {
let generated_field_name =
model.field_name_override(field_name).unwrap_or(field_name);
with_arguments_args_type_parameter_name(generated_field_name)
},
alternate_annotation: function
.alternate_type
.as_ref()
.map(typescript_authored_type_annotation),
})
.collect()
})
.unwrap_or_default();
}
fn ensure_rendered_enum(&mut self, planned_enum: &EnumSpec) {
self.enums
.entry(planned_enum.full_name.clone())
.or_insert_with(|| RenderedEnum {
name: planned_enum.name.clone(),
values: planned_enum
.values
.iter()
.map(|value| RenderedEnumValue {
name: value.name.clone(),
number: value.number,
})
.collect(),
});
}
fn ensure_rendered_flags(&mut self, planned_flags: &PlannedFlags) {
self.flags
.entry(planned_flags.full_name.clone())
.or_insert_with(|| RenderedFlags {
name: planned_flags.name.clone(),
flags: planned_flags
.flags
.iter()
.map(|flag| RenderedFlag {
name: flag.name.clone(),
bit: flag.bit,
})
.collect(),
});
}
fn ensure_rendered_variant(&mut self, planned_variant: &PlannedVariant) {
if self.variants.contains_key(&planned_variant.full_name) {
return;
}
let cases = planned_variant
.cases
.iter()
.map(|case| {
let payload = case
.payload
.as_ref()
.map(|payload| self.resolve_planned_value_type(payload));
RenderedVariantCase {
name: case.name.clone(),
payload_annotation: payload.as_ref().map(|payload| payload.annotation.clone()),
requirements: payload
.as_ref()
.map(|payload| payload.requirements.clone())
.unwrap_or_default(),
}
})
.collect();
self.variants.insert(
planned_variant.full_name.clone(),
RenderedVariant {
name: planned_variant.name.clone(),
type_parameters: self
.api_plan
.variant_type_parameters(&planned_variant.full_name, Language::TypeScript)
.into_iter()
.map(|usage| usage.parameter.name)
.collect(),
cases,
},
);
}
fn build_field(
&mut self,
record: &RecordSpec<PlannedFamily>,
field_name: &str,
field: &RecordFieldSpec<PlannedFamily>,
) -> RenderedField {
let generated_field_name = typescript_generated_field_name(&field.name);
let wire_field_name = typescript_generated_field_name(field_name);
let wire_field_expr = format!("{WIRE_VALUE_EXPR}.{wire_field_name}");
self.build_field_with_wire_expr(
record,
field_name,
field,
&generated_field_name,
&wire_field_name,
&wire_field_expr,
)
}
fn build_field_with_wire_expr(
&mut self,
record: &RecordSpec<PlannedFamily>,
field_name: &str,
field: &RecordFieldSpec<PlannedFamily>,
generated_field_name: &str,
wire_field_name: &str,
wire_field_expr: &str,
) -> RenderedField {
let doc = field
.doc
.as_ref()
.and_then(|doc| doc.for_language(Language::TypeScript))
.map(str::to_string);
if let PlannedType::Map(_, value) = &field.field_type {
let value_type = self.resolve_planned_value_type(value);
return RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: Self::typescript_field_annotation(
field,
format!("Record<string, {}>", value_type.annotation),
),
doc,
optional: true,
from_wire_expr: map_value_from_wire_expr(&value_type, wire_field_expr),
to_wire_expr: map_value_to_wire_expr_from_expr(
&value_type,
&format!("model.{generated_field_name}"),
),
flattened_fields: Vec::new(),
requirements: value_type.requirements,
};
}
let (resolved_type, repeated) = match &field.field_type {
PlannedType::List(value) => (self.resolve_planned_value_type(value), true),
PlannedType::Map(_, _) => unreachable!("handled above"),
value => (self.resolve_planned_value_type(value), false),
};
let owner_name = record.name.clone();
if !repeated
&& field.function.is_none()
&& record.function_for_args_field(field_name).is_none()
&& let Some(flattened) = self.build_flattened_message_field(
record,
field_name,
field,
&generated_field_name,
&wire_field_name,
)
{
return flattened;
}
if field
.function
.as_ref()
.and_then(typescript_function_type_descriptor)
.is_some()
{
let function = field
.function
.as_ref()
.expect("checked that function is present");
let function_name_extractor = function.name_extractor.as_deref();
let function_converter = function.converter.as_deref();
let from_wire_expr = function_field_from_wire_expr(
&resolved_type,
&owner_name,
wire_field_expr,
&generated_field_name,
field,
function,
);
return RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: Self::typescript_field_annotation(
field,
resolved_type.annotation.clone(),
),
doc,
optional: !field.required,
from_wire_expr,
to_wire_expr: match (field.required, function_name_extractor, function_converter) {
(true, Some(extractor), _) => required_function_name_to_wire_expr(
&resolved_type,
&owner_name,
&generated_field_name,
extractor,
),
(false, Some(extractor), _) => optional_function_name_to_wire_expr(
&resolved_type,
&generated_field_name,
extractor,
),
(true, None, Some(converter)) => required_function_to_wire_expr(
&owner_name,
&generated_field_name,
converter,
),
(false, None, Some(converter)) => {
optional_function_to_wire_expr(&generated_field_name, converter)
}
(true, None, None) => {
required_to_wire_expr(&resolved_type, &owner_name, &generated_field_name)
}
(false, None, None) => {
optional_to_wire_expr(&resolved_type, &generated_field_name)
}
},
requirements: resolved_type.requirements,
flattened_fields: Vec::new(),
};
}
if let Some(function_field) = &field.function {
let from_wire_expr = function_field_from_wire_expr(
&resolved_type,
&owner_name,
wire_field_expr,
&generated_field_name,
field,
function_field,
);
return RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: Self::typescript_field_annotation(
field,
resolved_type.annotation.clone(),
),
doc,
optional: !field.required,
from_wire_expr,
to_wire_expr: match (field.required, function_field.converter.as_deref()) {
(true, Some(converter)) => required_function_to_wire_expr(
&owner_name,
&generated_field_name,
converter,
),
(false, Some(converter)) => {
optional_function_to_wire_expr(&generated_field_name, converter)
}
(true, None) => {
required_to_wire_expr(&resolved_type, &owner_name, &generated_field_name)
}
(false, None) => optional_to_wire_expr(&resolved_type, &generated_field_name),
},
requirements: resolved_type.requirements,
flattened_fields: Vec::new(),
};
}
if record.function_for_args_field(field_name).is_some() {
return RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: resolved_type.annotation.clone(),
doc,
optional: !field.required,
from_wire_expr: format!("requestArgsFromPayloads({wire_field_expr}) as any"),
to_wire_expr: if field.required {
required_to_wire_expr(&resolved_type, &owner_name, &generated_field_name)
} else {
optional_function_args_to_wire_expr(&resolved_type, &generated_field_name)
},
flattened_fields: Vec::new(),
requirements: resolved_type.requirements,
};
}
if repeated {
return RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: Self::typescript_field_annotation(
field,
typescript_array_annotation(&resolved_type.annotation),
),
doc,
optional: true,
from_wire_expr: repeated_from_wire_expr(&resolved_type, wire_field_expr),
to_wire_expr: repeated_to_wire_expr(&resolved_type, &generated_field_name),
flattened_fields: Vec::new(),
requirements: resolved_type.requirements,
};
}
if let Some(default_value) = &field.default_value {
return RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: Self::typescript_field_annotation(
field,
resolved_type.annotation.clone(),
),
doc,
optional: true,
from_wire_expr: defaulted_enum_from_wire_expr(
&resolved_type,
wire_field_expr,
&default_value.enum_case,
),
to_wire_expr: defaulted_enum_to_wire_expr(
&resolved_type,
&generated_field_name,
&default_value.enum_case,
),
flattened_fields: Vec::new(),
requirements: resolved_type.requirements,
};
}
if field.required {
let from_wire_expr = required_from_wire_expr(
&resolved_type,
&owner_name,
wire_field_expr,
&generated_field_name,
field,
);
return RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: Self::typescript_field_annotation(
field,
resolved_type.annotation.clone(),
),
doc,
optional: false,
from_wire_expr: annotated_from_wire_expr(field, &resolved_type, from_wire_expr),
to_wire_expr: required_to_wire_expr(
&resolved_type,
&owner_name,
&generated_field_name,
),
flattened_fields: Vec::new(),
requirements: resolved_type.requirements,
};
}
let from_wire_expr = optional_from_wire_expr(&resolved_type, wire_field_expr);
RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: Self::typescript_field_annotation(field, resolved_type.annotation.clone()),
doc,
optional: true,
from_wire_expr: annotated_from_wire_expr(field, &resolved_type, from_wire_expr),
to_wire_expr: optional_to_wire_expr(&resolved_type, &generated_field_name),
flattened_fields: Vec::new(),
requirements: resolved_type.requirements,
}
}
fn build_flattened_message_field(
&mut self,
_record: &RecordSpec<PlannedFamily>,
_field_name: &str,
field: &RecordFieldSpec<PlannedFamily>,
generated_field_name: &str,
wire_field_name: &str,
) -> Option<RenderedField> {
let PlannedType::Record(record) = &field.field_type else {
return None;
};
let full_name = &record.full_name;
let nested_planned_model = self.api_plan.record(full_name)?.clone();
if !nested_planned_model.flatten_in_api {
return None;
}
let nested_model_name = nested_planned_model.name.clone();
let mut flattened_fields = Vec::new();
let mut requirements = TypeScriptRequirements::default();
let containing_wire_expr = format!("{WIRE_VALUE_EXPR}.{wire_field_name}");
for (nested_field_name, nested_planned_field) in nested_planned_model.public_fields() {
let nested_generated_field_name =
typescript_generated_field_name(&nested_planned_field.name);
let nested_wire_field_name = typescript_generated_field_name(nested_field_name);
let nested_wire_expr = format!("{containing_wire_expr}.{nested_wire_field_name}");
let nested_rendered_field = self.build_field_with_wire_expr(
&nested_planned_model,
nested_field_name,
nested_planned_field,
&nested_generated_field_name,
&nested_wire_field_name,
&nested_wire_expr,
);
let annotation = nested_planned_model
.field_flattened_annotation(nested_field_name)
.and_then(|annotation| annotation.for_language(Language::TypeScript))
.map(str::to_string)
.unwrap_or_else(|| nested_rendered_field.annotation.clone());
let to_wire_expr =
Self::flattened_field_to_wire_expr(&nested_rendered_field, &annotation);
let from_wire_expr = flattened_field_from_wire_expr(
&containing_wire_expr,
&nested_rendered_field.from_wire_expr,
&nested_rendered_field.annotation,
&annotation,
);
requirements.merge(&nested_rendered_field.requirements);
flattened_fields.push(RenderedFlattenedField {
name: nested_rendered_field.name,
wire_name: nested_rendered_field.wire_name,
annotation,
doc: nested_rendered_field.doc,
optional: nested_rendered_field.optional,
from_wire_expr,
to_wire_expr,
});
}
let doc = field
.doc
.as_ref()
.and_then(|doc| doc.for_language(Language::TypeScript))
.map(str::to_string);
Some(RenderedField {
name: generated_field_name.to_string(),
wire_name: wire_field_name.to_string(),
annotation: nested_model_name,
doc,
optional: !field.required,
from_wire_expr: String::new(),
to_wire_expr: Self::flattened_message_to_wire_expr(field.required, &flattened_fields),
flattened_fields,
requirements,
})
}
fn flattened_field_to_wire_expr(field: &RenderedField, annotation: &str) -> String {
if field.annotation == "common.Payload" && annotation != field.annotation {
let value_expr = format!("model.{}", field.name);
if field.optional {
return format!(
"{value_expr} == null ? undefined : configuredPayloadConverter().toPayload({value_expr})"
);
}
return format!("configuredPayloadConverter().toPayload({value_expr})");
}
field.to_wire_expr.clone()
}
fn flattened_message_to_wire_expr(required: bool, fields: &[RenderedFlattenedField]) -> String {
let wire_fields = fields
.iter()
.map(|field| format!("{}: {}", field.wire_name, field.to_wire_expr))
.collect::<Vec<_>>()
.join(", ");
if required || fields.iter().any(|field| !field.optional) {
return format!("{{ {wire_fields} }}");
}
let all_empty = fields
.iter()
.map(|field| format!("model.{} == null", field.name))
.collect::<Vec<_>>()
.join(" && ");
format!("{all_empty} ? undefined : {{ {wire_fields} }}")
}
fn build_sourced_field(
&mut self,
field_name: &str,
field: &RecordFieldSpec<PlannedFamily>,
source_expr: &str,
) -> RenderedSourcedField {
let field_name = typescript_generated_field_name(field_name);
if let PlannedType::Map(_, value) = &field.field_type {
let value_type = self.resolve_planned_value_type(value);
return RenderedSourcedField {
name: field_name.clone(),
to_wire_expr: map_value_to_wire_expr_from_expr(&value_type, source_expr),
};
}
let (resolved_type, repeated) = match &field.field_type {
PlannedType::List(value) => (self.resolve_planned_value_type(value), true),
PlannedType::Map(_, _) => unreachable!("handled above"),
value => (self.resolve_planned_value_type(value), false),
};
if repeated {
return RenderedSourcedField {
name: field_name.clone(),
to_wire_expr: repeated_to_wire_expr_from_expr(&resolved_type, source_expr),
};
}
RenderedSourcedField {
name: field_name,
to_wire_expr: optional_to_wire_expr_from_expr(&resolved_type, source_expr),
}
}
fn typescript_field_annotation(
field: &RecordFieldSpec<PlannedFamily>,
default_annotation: String,
) -> String {
let annotation = field
.annotation
.as_ref()
.and_then(|annotation| annotation.for_language(Language::TypeScript))
.map(str::to_string)
.unwrap_or(default_annotation);
if !field.required && annotation.ends_with(" | undefined") {
annotation
.strip_suffix(" | undefined")
.expect("suffix check should guarantee success")
.to_string()
} else {
annotation
}
}
fn resolve_planned_value_type(&mut self, value_type: &PlannedType) -> ResolvedFieldType {
match value_type {
PlannedType::TypeParameter(parameter) => ResolvedFieldType {
annotation: parameter.name.clone(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::Float => ResolvedFieldType {
annotation: "number".to_string(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::Int(IntSpec::I32) => ResolvedFieldType {
annotation: "number".to_string(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::Int(IntSpec::I64) => ResolvedFieldType {
annotation: "Long".to_string(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements { long: true },
wire_conversion: None,
},
PlannedType::Bool => ResolvedFieldType {
annotation: "boolean".to_string(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::String => ResolvedFieldType {
annotation: "string".to_string(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::Bytes => ResolvedFieldType {
annotation: "Uint8Array".to_string(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::Enum(enum_type) => {
if let Some(planned_enum) = self.api_plan.enum_decl(&enum_type.full_name) {
let planned_enum = planned_enum.clone();
self.ensure_rendered_enum(&planned_enum);
}
ResolvedFieldType {
annotation: enum_type.name.clone(),
kind: ResolvedFieldKind::Enum,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
}
}
PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Enum(_))) => {
ResolvedFieldType {
annotation: self
.external_models
.model_type_annotation(value_type)
.expect("external enum annotation should be present"),
kind: ResolvedFieldKind::Enum,
requirements: TypeScriptRequirements::default(),
wire_conversion: self.external_models.wire_conversion(value_type, None),
}
}
PlannedType::Flags(flags_type) => {
if let Some(planned_flags) = self.api_plan.flags_decl(&flags_type.full_name) {
let planned_flags = planned_flags.clone();
self.ensure_rendered_flags(&planned_flags);
}
ResolvedFieldType {
annotation: flags_type.name.clone(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
}
}
PlannedType::Variant(variant_type) => {
if let Some(planned_variant) = self.api_plan.variant(&variant_type.full_name) {
let planned_variant = planned_variant.clone();
self.ensure_rendered_variant(&planned_variant);
}
ResolvedFieldType {
annotation: generic_type_annotation(
&variant_type.name,
&self
.api_plan
.variant_type_parameters(&variant_type.full_name, Language::TypeScript)
.into_iter()
.map(|usage| usage.parameter.name)
.collect::<Vec<_>>(),
),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
}
}
message_type @ (PlannedType::External(ExternalTypeSpec::Proto(
PlannedProtoType::Message(_),
))
| PlannedType::Record(_)) => {
let conversion = self.resolve_message_value_conversion(message_type);
let annotation = if let PlannedType::Record(record) = message_type {
let parameters = self
.api_plan
.record_type_parameters(&record.full_name, Language::TypeScript)
.into_iter()
.map(|usage| RenderedTypeParameter {
name: usage.parameter.name,
constraint: "unknown".to_string(),
default: "unknown".to_string(),
infer_from_operation_request: true,
explicit: true,
})
.collect::<Vec<_>>();
generic_model_annotation(&conversion.annotation, ¶meters)
} else {
conversion.annotation.clone()
};
ResolvedFieldType {
annotation,
kind: ResolvedFieldKind::Message,
requirements: TypeScriptRequirements::default(),
wire_conversion: Some(conversion),
}
}
PlannedType::External(ExternalTypeSpec::Json(json_type)) => ResolvedFieldType {
annotation: json_type.model_name.clone(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::Resource(resource) => ResolvedFieldType {
annotation: resource.type_name.clone(),
kind: ResolvedFieldKind::Scalar,
requirements: TypeScriptRequirements::default(),
wire_conversion: None,
},
PlannedType::Option(inner) => {
let inner = self.resolve_planned_value_type(inner);
ResolvedFieldType {
annotation: format!("{} | undefined", inner.annotation),
kind: inner.kind,
requirements: inner.requirements,
wire_conversion: inner.wire_conversion,
}
}
PlannedType::List(inner) => {
let inner = self.resolve_planned_value_type(inner);
ResolvedFieldType {
annotation: typescript_array_annotation(&inner.annotation),
kind: ResolvedFieldKind::Scalar,
requirements: inner.requirements,
wire_conversion: None,
}
}
PlannedType::Map(key, value) => {
let key = self.resolve_planned_value_type(key);
let value = self.resolve_planned_value_type(value);
let mut requirements = key.requirements;
requirements.merge(&value.requirements);
ResolvedFieldType {
annotation: format!("Record<{}, {}>", key.annotation, value.annotation),
kind: ResolvedFieldKind::Scalar,
requirements,
wire_conversion: None,
}
}
PlannedType::Tuple(items) => {
let item_types = items
.iter()
.map(|item| self.resolve_planned_value_type(item))
.collect::<Vec<_>>();
let mut requirements = TypeScriptRequirements::default();
for item_type in &item_types {
requirements.merge(&item_type.requirements);
}
ResolvedFieldType {
annotation: format!(
"[{}]",
item_types
.iter()
.map(|item| item.annotation.as_str())
.collect::<Vec<_>>()
.join(", ")
),
kind: ResolvedFieldKind::Scalar,
requirements,
wire_conversion: None,
}
}
PlannedType::Result { ok, err } => {
let ok = ok.as_ref().map(|ok| self.resolve_planned_value_type(ok));
let err = err.as_ref().map(|err| self.resolve_planned_value_type(err));
let mut requirements = TypeScriptRequirements::default();
if let Some(ok) = &ok {
requirements.merge(&ok.requirements);
}
if let Some(err) = &err {
requirements.merge(&err.requirements);
}
ResolvedFieldType {
annotation: typescript_result_annotation(
ok.as_ref().map(|ok| ok.annotation.clone()),
err.as_ref().map(|err| err.annotation.clone()),
),
kind: ResolvedFieldKind::Scalar,
requirements,
wire_conversion: None,
}
}
PlannedType::External(ExternalTypeSpec::Alias {
type_name,
target: fallback,
..
}) => {
let mut resolved = self.resolve_planned_value_type(fallback);
if let Some(annotation) = type_name.for_language(Language::TypeScript) {
resolved.annotation = annotation.to_string();
if annotation.contains("Long") {
resolved.requirements.long = true;
}
}
resolved
}
}
}
}
fn repeated_from_wire_expr(resolved_type: &ResolvedFieldType, wire_value_expr: &str) -> String {
match resolved_type.kind {
ResolvedFieldKind::Message => format!(
"{wire_value_expr}?.map((value: any) => {}!)",
resolved_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.from_wire_expr("value")
),
ResolvedFieldKind::Enum => format!(
"{wire_value_expr}?.map((value: any) => {})",
enum_from_wire_expr(resolved_type, "value")
),
_ => format!("{wire_value_expr}?.slice()"),
}
}
fn repeated_to_wire_expr(resolved_type: &ResolvedFieldType, field_name: &str) -> String {
repeated_to_wire_expr_from_expr(resolved_type, &format!("model.{field_name}"))
}
fn repeated_to_wire_expr_from_expr(resolved_type: &ResolvedFieldType, value_expr: &str) -> String {
match resolved_type.kind {
ResolvedFieldKind::Message => format!(
"{value_expr}?.map((value) => {})",
resolved_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.to_wire_expr("value")
),
ResolvedFieldKind::Enum => format!(
"{value_expr}?.map((value) => {})",
enum_to_wire_expr(resolved_type, "value")
),
_ => format!("{value_expr}?.slice()"),
}
}
fn map_value_from_wire_expr(map_value_type: &ResolvedFieldType, wire_value_expr: &str) -> String {
let value_expr = match map_value_type.kind {
ResolvedFieldKind::Message => map_value_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.from_wire_expr("value"),
ResolvedFieldKind::Enum => enum_from_wire_expr(map_value_type, "value"),
_ => format!("value as {}", map_value_type.annotation),
};
format!(
"{wire_value_expr} == null ? undefined : (Object.fromEntries(Object.entries({wire_value_expr} as Record<string, any>).map(([key, value]: [string, any]) => [key, {value_expr}])) as Record<string, {}>)",
map_value_type.annotation
)
}
fn map_value_to_wire_expr_from_expr(
map_value_type: &ResolvedFieldType,
value_expr: &str,
) -> String {
let mapped_value_expr = match map_value_type.kind {
ResolvedFieldKind::Message => map_value_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.to_wire_expr("value"),
ResolvedFieldKind::Enum => enum_to_wire_expr(map_value_type, "value"),
_ => "value".to_string(),
};
format!(
"{value_expr} == null ? undefined : Object.fromEntries(Object.entries({value_expr} as Record<string, any>).map(([key, value]: [string, any]) => [key, {mapped_value_expr}]))"
)
}
fn required_from_wire_expr(
resolved_type: &ResolvedFieldType,
owner_name: &str,
wire_value_expr: &str,
generated_field_name: &str,
field: &RecordFieldSpec<PlannedFamily>,
) -> String {
let wire_value_expr = if field_has_presence(field) {
wire_value_expr.to_string()
} else if matches!(&field.field_type, PlannedType::String) {
format!("{wire_value_expr} === '' ? undefined : {wire_value_expr}")
} else if matches!(&field.field_type, PlannedType::Bytes) {
format!(
"{wire_value_expr} == null || {wire_value_expr}.length === 0 ? undefined : {wire_value_expr}"
)
} else {
format!("{wire_value_expr} ?? undefined")
};
let required_wire_expr =
required_field_expr(&wire_value_expr, owner_name, generated_field_name);
match resolved_type.kind {
ResolvedFieldKind::Message => required_field_expr(
&format!(
"({}) as {}",
resolved_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.from_wire_expr(&required_wire_expr),
resolved_type.annotation
),
owner_name,
generated_field_name,
),
ResolvedFieldKind::Enum => required_field_expr(
&enum_from_wire_expr(resolved_type, &required_wire_expr),
owner_name,
generated_field_name,
),
_ => required_wire_expr,
}
}
fn required_to_wire_expr(
resolved_type: &ResolvedFieldType,
owner_name: &str,
field_name: &str,
) -> String {
let required_value =
required_field_expr(&format!("model.{field_name}"), owner_name, field_name);
match resolved_type.kind {
ResolvedFieldKind::Message => resolved_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.to_wire_expr(&required_value),
ResolvedFieldKind::Enum => enum_to_wire_expr(resolved_type, &required_value),
_ => required_value,
}
}
fn optional_from_wire_expr(resolved_type: &ResolvedFieldType, wire_value_expr: &str) -> String {
match resolved_type.kind {
ResolvedFieldKind::Message => format!(
"{wire_value_expr} == null ? undefined : ({}) as {}",
resolved_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.from_wire_expr(wire_value_expr),
resolved_type.annotation
),
ResolvedFieldKind::Enum => format!(
"{wire_value_expr} == null ? undefined : {}",
enum_from_wire_expr(resolved_type, wire_value_expr)
),
_ => format!("{wire_value_expr} ?? undefined"),
}
}
fn function_field_from_wire_expr(
resolved_type: &ResolvedFieldType,
owner_name: &str,
wire_value_expr: &str,
generated_field_name: &str,
field: &RecordFieldSpec<PlannedFamily>,
function: &FunctionFieldSpec<PlannedFamily>,
) -> String {
let expression = if field.required {
required_from_wire_expr(
resolved_type,
owner_name,
wire_value_expr,
generated_field_name,
field,
)
} else {
optional_from_wire_expr(resolved_type, wire_value_expr)
};
let Some(alternate_type) = &function.alternate_type else {
return expression;
};
let mut annotation = typescript_authored_type_annotation(alternate_type);
if !field.required {
annotation.push_str(" | undefined");
}
format!("({expression}) as {annotation}")
}
fn annotated_from_wire_expr(
field: &RecordFieldSpec<PlannedFamily>,
resolved_type: &ResolvedFieldType,
expression: String,
) -> String {
let annotation = field
.annotation
.as_ref()
.and_then(|annotation| annotation.for_language(Language::TypeScript))
.map(str::to_string);
let Some(annotation) = annotation else {
return expression;
};
if annotation == resolved_type.annotation {
expression
} else {
format!("({expression}) as {annotation}")
}
}
fn defaulted_enum_from_wire_expr(
resolved_type: &ResolvedFieldType,
wire_value_expr: &str,
enum_case: &str,
) -> String {
let default_expr = enum_default_expr(resolved_type, enum_case);
format!(
"{wire_value_expr} == null ? {default_expr} : {}",
enum_from_wire_expr(resolved_type, wire_value_expr)
)
}
fn flattened_field_from_wire_expr(
containing_wire_expr: &str,
nested_field_from_wire_expr: &str,
nested_annotation: &str,
flattened_annotation: &str,
) -> String {
let nested_field_from_wire_expr = if nested_annotation == "common.Payload"
&& flattened_annotation != nested_annotation
{
format!(
"{nested_field_from_wire_expr} == null ? undefined : configuredPayloadConverter().fromPayload<{flattened_annotation}>(({nested_field_from_wire_expr})!)"
)
} else {
nested_field_from_wire_expr.to_string()
};
format!("{containing_wire_expr} == null ? undefined : {nested_field_from_wire_expr}")
}
fn optional_to_wire_expr(resolved_type: &ResolvedFieldType, field_name: &str) -> String {
optional_to_wire_expr_from_expr(resolved_type, &format!("model.{field_name}"))
}
fn optional_to_wire_expr_from_expr(resolved_type: &ResolvedFieldType, value_expr: &str) -> String {
match resolved_type.kind {
ResolvedFieldKind::Message => format!(
"{value_expr} == null ? undefined : {}",
resolved_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.to_wire_expr(value_expr)
),
ResolvedFieldKind::Enum => format!(
"{value_expr} == null ? undefined : {}",
enum_to_wire_expr(resolved_type, value_expr)
),
_ => value_expr.to_string(),
}
}
fn defaulted_enum_to_wire_expr(
resolved_type: &ResolvedFieldType,
field_name: &str,
enum_case: &str,
) -> String {
let value_expr = format!("model.{field_name}");
let default_expr = enum_default_expr(resolved_type, enum_case);
enum_to_wire_expr(
resolved_type,
&format!("({value_expr} == null ? {default_expr} : {value_expr})"),
)
}
fn required_function_to_wire_expr(owner_name: &str, field_name: &str, converter: &str) -> String {
let required_value =
required_field_expr(&format!("model.{field_name}"), owner_name, field_name);
format!("{converter}({required_value})")
}
fn optional_function_to_wire_expr(field_name: &str, converter: &str) -> String {
let value_expr = format!("model.{field_name}");
format!("{value_expr} == null ? undefined : {converter}({value_expr})")
}
fn required_function_name_to_wire_expr(
resolved_type: &ResolvedFieldType,
owner_name: &str,
field_name: &str,
name_extractor: &str,
) -> String {
let required_value =
required_field_expr(&format!("model.{field_name}"), owner_name, field_name);
function_value_to_wire_expr(
resolved_type,
&format!("{name_extractor}({required_value})"),
)
}
fn optional_function_name_to_wire_expr(
resolved_type: &ResolvedFieldType,
field_name: &str,
name_extractor: &str,
) -> String {
let value_expr = format!("model.{field_name}");
format!(
"{value_expr} == null ? undefined : {}",
function_value_to_wire_expr(resolved_type, &format!("{name_extractor}({value_expr})"),)
)
}
fn optional_function_args_to_wire_expr(
_resolved_type: &ResolvedFieldType,
field_name: &str,
) -> String {
format!("requestArgsToPayloads(model.{field_name})")
}
fn field_has_presence(field: &RecordFieldSpec<PlannedFamily>) -> bool {
field.data.has_presence.unwrap_or(!field.required)
}
fn function_value_to_wire_expr(resolved_type: &ResolvedFieldType, name_expr: &str) -> String {
match resolved_type.kind {
ResolvedFieldKind::Message => resolved_type
.wire_conversion
.as_ref()
.expect("message conversion should be present")
.function_name_to_wire_expr(name_expr),
_ => name_expr.to_string(),
}
}
fn enum_from_wire_expr(resolved_type: &ResolvedFieldType, expr: &str) -> String {
if let Some(wire_conversion) = &resolved_type.wire_conversion {
wire_conversion.from_wire_expr(expr)
} else {
format!("{expr} as {}", resolved_type.annotation)
}
}
fn enum_default_expr(resolved_type: &ResolvedFieldType, enum_case: &str) -> String {
let value_name = if resolved_type.wire_conversion.is_some() {
enum_case.to_shouty_snake_case()
} else {
enum_case.to_upper_camel_case()
};
format!("{}.{}", resolved_type.annotation, value_name)
}
fn enum_to_wire_expr(resolved_type: &ResolvedFieldType, expr: &str) -> String {
if let Some(wire_conversion) = &resolved_type.wire_conversion {
wire_conversion.to_wire_expr(expr)
} else {
format!("{expr} as any")
}
}
trait PlannedOperationExt {
fn output_direct_result(&self) -> bool;
}
impl PlannedOperationExt for PlannedOperation {
fn output_direct_result(&self) -> bool {
operation_output_direct_result(self)
}
}
pub(crate) fn generate(
tree: &crate::spec::ApiSpecTree<PlannedFamily>,
support: &crate::SupportFiles,
mode: GenerationMode,
ts_date_time_types: TsDateTimeTypes,
) -> Result<GeneratedFiles> {
match &tree.root {
ApiSpecNode::Leaf(leaf) => {
let support_fragments = support_fragments_for_plan(&leaf.spec, support);
generate_leaf(&leaf.spec, &support_fragments, mode, ts_date_time_types)
}
ApiSpecNode::Branch(branch) => generate_tree(branch, support, mode, ts_date_time_types),
}
}
fn generate_leaf(
api_plan: &PlannedSpec,
support_fragments: &[SupportFragmentSpec],
mode: GenerationMode,
ts_date_time_types: TsDateTimeTypes,
) -> Result<GeneratedFiles> {
reject_support_namespaces(Language::TypeScript, support_fragments)?;
let language_imports = collect_typescript_language_imports(api_plan);
let mut planner = ApiPlanner::new(api_plan, ts_date_time_types)?;
let services = api_plan
.services
.iter()
.map(|service| {
let operations = service
.operations
.iter()
.map(|operation| planner.resolve_operation(operation))
.collect::<Result<Vec<_>>>()?;
Ok(RenderedService {
name: service.name.as_str(),
attr_name: service
.code_name
.for_language(Language::TypeScript)
.map(str::to_string)
.unwrap_or_else(|| typescript_ident(&service.name.to_lower_camel_case())),
wire_name: service.wire_name.as_str(),
doc: service
.doc
.for_language(Language::TypeScript)
.map(str::to_string),
endpoint: service.endpoint.clone(),
experimental: service.experimental,
deprecated: service.deprecated,
operations,
resources: service
.resources
.iter()
.map(|resource| resource.data.clone())
.collect(),
})
})
.collect::<Result<Vec<_>>>()?;
for service in &services {
for resource in &service.resources {
planner.ensure_resource_field_types(resource);
}
}
for record in api_plan.records().map(|(_, record)| record) {
let model_type = TypeSpec::Record(PlannedRecordType {
full_name: record.full_name.clone(),
model_name: record.name.clone(),
});
planner.resolve_message_value_conversion(&model_type);
}
let requirements = collect_typescript_requirements(
planner.variants.values().collect::<Vec<_>>().as_slice(),
planner.models.values().collect::<Vec<_>>().as_slice(),
&services,
);
let support_source = support_source(support_fragments);
let model_fragments = planner.render_external_models()?;
render_module_files(
planner.enums.values().collect::<Vec<_>>().as_slice(),
planner.flags.values().collect::<Vec<_>>().as_slice(),
planner.variants.values().collect::<Vec<_>>().as_slice(),
planner.models.values().collect::<Vec<_>>().as_slice(),
&planner.external_models,
&model_fragments,
&services,
&requirements,
&language_imports,
support_source.as_deref(),
api_plan,
mode,
)
}
fn collect_typescript_language_imports(api_plan: &PlannedSpec) -> Vec<LanguageImportSpec> {
let mut imports = BTreeSet::new();
for variant in api_plan.variants().map(|(_, variant)| variant) {
for case in &variant.cases {
if let Some(payload) = &case.payload {
collect_value_type_imports(payload, &mut imports);
}
}
}
for model in api_plan.records().map(|(_, record)| record) {
if let Some(proto) = &model.data.proto {
collect_proto_type_imports(proto, &mut imports);
}
for field in model.fields.values() {
if let Some(annotation) = &field.annotation {
collect_typescript_import(annotation, true, false, &mut imports);
}
if let Some(annotation) = &field.flattened_annotation {
collect_typescript_import(annotation, true, false, &mut imports);
}
match &field.data.wire_binding {
Some(PlannedWireFieldBinding::Value { wire_type, .. }) => {
collect_value_type_imports(wire_type, &mut imports);
}
Some(PlannedWireFieldBinding::VariantMembers { members, .. }) => {
for member in members {
collect_value_type_imports(&member.wire_type, &mut imports);
}
}
None => {}
}
}
for (_, field) in model.model_fields() {
if let Some(function) = &field.function {
collect_function_imports(function, &mut imports);
}
collect_value_type_imports(&field.field_type, &mut imports);
}
for (_, sourced_field, _) in model.sourced_fields() {
collect_value_type_imports(&sourced_field.field_type, &mut imports);
}
}
for service in &api_plan.services {
for operation in &service.operations {
if let Some(input) = operation_input_model(operation) {
collect_model_type_imports(input, &mut imports);
}
match operation.output_type() {
Some(
model_type @ (PlannedType::External(ExternalTypeSpec::Proto(
PlannedProtoType::Message(_),
))
| PlannedType::External(ExternalTypeSpec::Json(_))
| PlannedType::Record(_)),
) => collect_model_type_imports(model_type, &mut imports),
Some(PlannedType::Resource(resource)) => {
if let Some(model_type) = &resource.wire_type {
collect_model_type_imports(
&PlannedType::External(model_type.clone()),
&mut imports,
);
}
}
_ => {}
}
if let Some(transform) = &operation.output_transform {
collect_typescript_import(&transform.type_name, true, false, &mut imports);
}
}
for resource in service.resources.iter().map(|resource| &resource.data) {
for field in &resource.fields {
collect_value_type_imports(&field.kind, &mut imports);
if let Some(function) = &field.function {
collect_function_imports(function, &mut imports);
}
}
for method in &resource.methods {
for param in &method.params {
collect_value_type_imports(¶m.kind, &mut imports);
if let Some(function) = ¶m.function {
collect_function_imports(function, &mut imports);
}
}
if let Some(result) = &method.result {
if let PlannedResourceMethodResultKind::Value(kind) = &result.kind {
collect_value_type_imports(kind, &mut imports);
}
}
}
}
}
imports.into_iter().collect()
}
fn collect_proto_type_imports(
proto: &PlannedProtoTypeInfo,
imports: &mut BTreeSet<LanguageImportSpec>,
) {
collect_typescript_import(&proto.reference, true, true, imports);
collect_typescript_import(&proto.type_name, true, false, imports);
}
fn collect_model_type_imports(
model_type: &PlannedType,
imports: &mut BTreeSet<LanguageImportSpec>,
) {
match model_type {
PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Message(proto))) => {
collect_proto_type_imports(&proto.proto, imports);
if let Some(replacement) = &proto.replacement {
collect_type_replacement_imports(replacement, imports);
}
if let Some(authored_type) = &proto.authored_type {
collect_authored_type_imports(authored_type, imports);
}
}
PlannedType::Record(_) => {}
_ => {}
}
}
fn collect_type_replacement_imports(
replacement: &TypeReplacementSpec,
imports: &mut BTreeSet<LanguageImportSpec>,
) {
collect_typescript_import(&replacement.type_name, true, false, imports);
collect_typescript_import(&replacement.from_proto, false, false, imports);
collect_typescript_import(&replacement.to_proto, false, false, imports);
}
fn collect_value_type_imports(value: &PlannedType, imports: &mut BTreeSet<LanguageImportSpec>) {
match value {
PlannedType::Enum(_) | PlannedType::TypeParameter(_) => {}
PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Enum(enumeration))) => {
collect_proto_type_imports(&enumeration.proto, imports);
if let Some(replacement) = &enumeration.replacement {
collect_type_replacement_imports(replacement, imports);
}
}
PlannedType::Flags(_) | PlannedType::Variant(_) => {}
PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Message(_)))
| PlannedType::External(ExternalTypeSpec::Json(_))
| PlannedType::Record(_) => {
collect_model_type_imports(value, imports);
}
PlannedType::Resource(resource) => {
if let Some(wire_type) = &resource.wire_type {
collect_model_type_imports(&PlannedType::External(wire_type.clone()), imports);
}
}
PlannedType::Option(inner) | PlannedType::List(inner) => {
collect_value_type_imports(inner, imports)
}
PlannedType::Map(key, value) => {
collect_value_type_imports(key, imports);
collect_value_type_imports(value, imports);
}
PlannedType::Tuple(items) => {
for item in items {
collect_value_type_imports(item, imports);
}
}
PlannedType::Result { ok, err } => {
if let Some(ok) = ok {
collect_value_type_imports(ok, imports);
}
if let Some(err) = err {
collect_value_type_imports(err, imports);
}
}
PlannedType::External(ExternalTypeSpec::Alias {
type_name,
target: fallback,
..
}) => {
collect_typescript_import(type_name, true, false, imports);
collect_value_type_imports(fallback, imports);
}
PlannedType::Bool
| PlannedType::Int(_)
| PlannedType::Float
| PlannedType::String
| PlannedType::Bytes => {}
}
}
fn collect_function_imports(
function: &FunctionFieldSpec<PlannedFamily>,
imports: &mut BTreeSet<LanguageImportSpec>,
) {
if let FunctionResultSpec::Annotation(result) = &function.result {
collect_typescript_import(result, true, false, imports);
}
collect_function_args_imports(&function.args, imports);
if let Some(alternate_type) = &function.alternate_type {
collect_authored_type_imports(alternate_type, imports);
}
if let Some(descriptor) = &function.type_descriptor {
collect_typescript_import(&descriptor.value_type, true, false, imports);
collect_typescript_import(&descriptor.args_type, true, false, imports);
}
}
fn collect_function_args_imports(
args: &FunctionArgsSpec<PlannedFamily>,
imports: &mut BTreeSet<LanguageImportSpec>,
) {
let fields = match args {
FunctionArgsSpec::Varargs { prefix, .. } => prefix,
FunctionArgsSpec::Fixed(fields) => fields,
};
for field in fields {
collect_authored_type_imports(&field.field_type, imports);
}
}
fn collect_authored_type_imports(
field_type: &PlannedType,
imports: &mut BTreeSet<LanguageImportSpec>,
) {
match field_type {
TypeSpec::Option(inner) | TypeSpec::List(inner) => {
collect_authored_type_imports(inner, imports);
}
TypeSpec::Tuple(items) => {
for item in items {
collect_authored_type_imports(item, imports);
}
}
TypeSpec::Map(key, value) => {
collect_authored_type_imports(key, imports);
collect_authored_type_imports(value, imports);
}
TypeSpec::Result { ok, err } => {
if let Some(ok) = ok {
collect_authored_type_imports(ok, imports);
}
if let Some(err) = err {
collect_authored_type_imports(err, imports);
}
}
TypeSpec::External(ExternalTypeSpec::Alias {
target, type_name, ..
}) => {
collect_typescript_import(type_name, true, false, imports);
collect_authored_type_imports(target, imports);
}
TypeSpec::TypeParameter(_)
| TypeSpec::Bool
| TypeSpec::Int(_)
| TypeSpec::Float
| TypeSpec::String
| TypeSpec::Bytes
| TypeSpec::External(ExternalTypeSpec::Proto(_))
| TypeSpec::External(ExternalTypeSpec::Json(_))
| TypeSpec::Record(_)
| TypeSpec::Enum(_)
| TypeSpec::Flags(_)
| TypeSpec::Variant(_)
| TypeSpec::Resource(_) => {}
}
}
fn collect_typescript_import(
spec: &LanguageStringSpec,
type_only: bool,
named_import: bool,
imports: &mut BTreeSet<LanguageImportSpec>,
) {
let Some(module) = spec.import_for_language(Language::TypeScript) else {
return;
};
let Some(expression) = spec.for_language(Language::TypeScript) else {
return;
};
for namespace in typescript_qualified_namespaces(expression) {
imports.insert(LanguageImportSpec {
language: Language::TypeScript,
reference: namespace.clone(),
module: module.to_string(),
name: Some(namespace),
type_only,
import_style: if named_import {
LanguageImportStyle::Named
} else {
LanguageImportStyle::Namespace
},
});
}
}
fn typescript_qualified_namespaces(expression: &str) -> BTreeSet<String> {
let chars = expression.char_indices().collect::<Vec<_>>();
let mut namespaces = BTreeSet::new();
let mut index = 0;
while index < chars.len() {
let (start_byte, ch) = chars[index];
if !is_typescript_identifier_start(ch) {
index += 1;
continue;
}
let before = expression[..start_byte].chars().next_back();
if before.is_some_and(|before| is_typescript_identifier_char(before) || before == '.') {
index += 1;
continue;
}
let mut end = index + 1;
while end < chars.len() && is_typescript_identifier_char(chars[end].1) {
end += 1;
}
let end_byte = chars
.get(end)
.map(|(byte, _)| *byte)
.unwrap_or(expression.len());
let mut after = end;
while after < chars.len() && chars[after].1.is_whitespace() {
after += 1;
}
if after < chars.len() && chars[after].1 == '.' {
namespaces.insert(expression[start_byte..end_byte].to_string());
}
index = end;
}
namespaces
}
fn reject_support_namespaces(
language: Language,
support_fragments: &[SupportFragmentSpec],
) -> Result<()> {
if let Some(namespace) = support_fragments
.iter()
.find_map(|fragment| fragment.namespace.as_deref())
{
return Err(Error::UnsupportedSupportNamespace {
language,
namespace: namespace.to_string(),
});
}
Ok(())
}
fn model_type_parameters(
model: &RecordSpec<PlannedFamily>,
api_plan: &PlannedSpec,
) -> Vec<RenderedTypeParameter> {
if model.is_empty_model() {
return Vec::new();
}
let mut model_parameters = api_plan
.record_type_parameters(&model.full_name, Language::TypeScript)
.into_iter()
.map(|usage| RenderedTypeParameter {
name: usage.parameter.name,
constraint: "unknown".to_string(),
default: "unknown".to_string(),
infer_from_operation_request: true,
explicit: true,
})
.collect::<Vec<_>>();
let mut type_parameters = Vec::new();
for (field_name, function) in model.functions() {
let generated_name = model.field_name_override(field_name).unwrap_or(field_name);
if let Some(descriptor) = typescript_function_type_descriptor(function) {
let value_parameter_name = with_arguments_type_parameter_name(generated_name);
let args_parameter_name = with_arguments_args_type_parameter_name(generated_name);
type_parameters.push((
true,
generated_name.to_string(),
0,
RenderedTypeParameter {
name: value_parameter_name.clone(),
constraint: descriptor.value_type.clone(),
default: descriptor.value_type,
infer_from_operation_request: true,
explicit: false,
},
));
type_parameters.push((
true,
generated_name.to_string(),
1,
RenderedTypeParameter {
name: args_parameter_name,
constraint: "any[]".to_string(),
default: with_arguments_args_type_expression(
&descriptor.args_type,
&value_parameter_name,
),
infer_from_operation_request: false,
explicit: false,
},
));
} else {
let generated_name = model.field_name_override(field_name).unwrap_or(field_name);
let type_parameter_name = function_type_parameter_name(generated_name);
let constraint = function_constraint(function);
type_parameters.push((
!function.primary,
generated_name.to_string(),
0,
RenderedTypeParameter {
name: type_parameter_name,
constraint: constraint.clone(),
default: constraint,
infer_from_operation_request: true,
explicit: false,
},
));
}
}
type_parameters.sort_by(|left, right| {
left.0
.cmp(&right.0)
.then_with(|| left.1.cmp(&right.1))
.then_with(|| left.2.cmp(&right.2))
});
model_parameters.extend(
type_parameters
.into_iter()
.map(|(_, _, _, type_parameter)| type_parameter),
);
model_parameters
}
fn typescript_function_result_annotation(result: &FunctionResultSpec<PlannedFamily>) -> String {
match result {
FunctionResultSpec::Annotation(annotation) => annotation
.for_language(Language::TypeScript)
.unwrap_or("unknown")
.to_string(),
FunctionResultSpec::Authored(authored_type) => {
typescript_authored_type_annotation(authored_type)
}
}
}
fn typescript_array_annotation(element: &str) -> String {
if typescript_annotation_needs_parens(element) {
format!("({element})[]")
} else {
format!("{element}[]")
}
}
fn typescript_annotation_needs_parens(annotation: &str) -> bool {
let mut depth = 0i32;
let mut chars = annotation.chars().peekable();
while let Some(c) = chars.next() {
match c {
'(' | '<' | '{' | '[' => depth += 1,
')' | '>' | '}' | ']' => depth -= 1,
'=' if chars.peek() == Some(&'>') => {
let _ = chars.next();
}
'|' | '&' if depth == 0 => return true,
_ => {}
}
}
false
}
pub(in crate::generator) fn typescript_authored_type_annotation(
authored_type: &PlannedType,
) -> String {
match authored_type {
TypeSpec::Bool => "boolean".to_string(),
TypeSpec::Int(IntSpec::I32) | TypeSpec::Float => "number".to_string(),
TypeSpec::Int(IntSpec::I64) => "Long".to_string(),
TypeSpec::String => "string".to_string(),
TypeSpec::Bytes => "Uint8Array".to_string(),
TypeSpec::TypeParameter(parameter) => parameter.name.clone(),
TypeSpec::Option(inner) => {
format!("{} | undefined", typescript_authored_type_annotation(inner))
}
TypeSpec::List(inner) => {
typescript_array_annotation(&typescript_authored_type_annotation(inner))
}
TypeSpec::Tuple(items) => format!(
"[{}]",
items
.iter()
.map(typescript_authored_type_annotation)
.collect::<Vec<_>>()
.join(", ")
),
TypeSpec::Map(key, value) => format!(
"Record<{}, {}>",
typescript_authored_type_annotation(key),
typescript_authored_type_annotation(value)
),
TypeSpec::Result { ok, err } => typescript_result_annotation(
ok.as_ref()
.map(|ok| typescript_authored_type_annotation(ok).to_string()),
err.as_ref()
.map(|err| typescript_authored_type_annotation(err).to_string()),
),
TypeSpec::External(ExternalTypeSpec::Alias {
type_name, target, ..
}) => type_name
.for_language(Language::TypeScript)
.map(str::to_string)
.unwrap_or_else(|| typescript_authored_type_annotation(target)),
TypeSpec::External(ExternalTypeSpec::Proto(wire_name)) => wire_name
.full_name()
.rsplit('.')
.next()
.unwrap_or(wire_name.full_name())
.to_string(),
TypeSpec::External(ExternalTypeSpec::Json(json_type)) => json_type.model_name.clone(),
TypeSpec::Record(name) => name
.full_name
.as_str()
.rsplit('.')
.next()
.unwrap_or(&name.full_name)
.to_upper_camel_case(),
TypeSpec::Enum(name) => name
.full_name
.as_str()
.rsplit('.')
.next()
.unwrap_or(&name.full_name)
.to_upper_camel_case(),
TypeSpec::Flags(name) => name
.full_name
.as_str()
.rsplit('.')
.next()
.unwrap_or(&name.full_name)
.to_upper_camel_case(),
TypeSpec::Variant(name) => name
.full_name
.as_str()
.rsplit('.')
.next()
.unwrap_or(&name.full_name)
.to_upper_camel_case(),
TypeSpec::Resource(name) => name
.type_name
.as_str()
.rsplit('.')
.next()
.unwrap_or(&name.type_name)
.to_upper_camel_case(),
}
}
fn typescript_result_annotation(ok: Option<String>, err: Option<String>) -> String {
let ok_case = if let Some(ok) = ok {
format!("{{ tag: \"ok\"; value: {ok} }}")
} else {
"{ tag: \"ok\" }".to_string()
};
let err_case = if let Some(err) = err {
format!("{{ tag: \"err\"; value: {err} }}")
} else {
"{ tag: \"err\" }".to_string()
};
format!("{ok_case} | {err_case}")
}
fn function_type_parameter_name(field_name: &str) -> String {
format!(
"{}Fn",
function_type_parameter_stem(field_name).to_upper_camel_case()
)
}
fn with_arguments_type_parameter_name(field_name: &str) -> String {
format!(
"{}Value",
function_type_parameter_stem(field_name).to_upper_camel_case()
)
}
fn with_arguments_args_type_parameter_name(field_name: &str) -> String {
format!(
"{}Args",
function_type_parameter_stem(field_name).to_upper_camel_case()
)
}
fn function_type_parameter_stem(field_name: &str) -> &str {
field_name
.strip_suffix("Type")
.or_else(|| field_name.strip_suffix("Name"))
.unwrap_or(field_name)
}
fn function_constraint(function: &FunctionFieldSpec<PlannedFamily>) -> String {
typescript_function_constraint(
&typescript_function_args(&function.args),
&typescript_function_result_annotation(&function.result),
)
}
#[derive(Debug, Clone)]
struct RenderedFunctionTypeDescriptor {
value_type: String,
args_type: String,
}
fn typescript_function_type_descriptor(
function: &FunctionFieldSpec<PlannedFamily>,
) -> Option<RenderedFunctionTypeDescriptor> {
let descriptor = function.type_descriptor.as_ref()?;
Some(RenderedFunctionTypeDescriptor {
value_type: descriptor
.value_type
.for_language(Language::TypeScript)?
.to_string(),
args_type: descriptor
.args_type
.for_language(Language::TypeScript)?
.to_string(),
})
}
fn typescript_function_constraint(args: &RenderedFunctionArgs, result_annotation: &str) -> String {
match args {
RenderedFunctionArgs::Varargs {
prefix,
typescript_drop_prefix,
} => {
if *typescript_drop_prefix {
return format!("(...args: any[]) => {result_annotation}");
}
let mut parameters = prefix
.iter()
.map(|parameter| format!("{}: {}", parameter.name, parameter.annotation))
.collect::<Vec<_>>();
parameters.push("...args: any[]".to_string());
format!("({}) => {result_annotation}", parameters.join(", "))
}
RenderedFunctionArgs::Typed { parameters } => format!(
"({}) => {result_annotation}",
parameters
.iter()
.map(|parameter| format!("{}: {}", parameter.name, parameter.annotation))
.collect::<Vec<_>>()
.join(", ")
),
}
}
fn typescript_function_args(args: &FunctionArgsSpec<PlannedFamily>) -> RenderedFunctionArgs {
match args {
FunctionArgsSpec::Varargs {
prefix,
typescript_drop_prefix,
} => RenderedFunctionArgs::Varargs {
prefix: prefix
.iter()
.map(|arg| RenderedFunctionArg {
name: typescript_ident(&arg.name.to_lower_camel_case()),
annotation: typescript_authored_type_annotation(&arg.field_type),
})
.collect(),
typescript_drop_prefix: *typescript_drop_prefix,
},
FunctionArgsSpec::Fixed(args) => RenderedFunctionArgs::Typed {
parameters: args
.iter()
.map(|arg| RenderedFunctionArg {
name: typescript_ident(&arg.name.to_lower_camel_case()),
annotation: typescript_authored_type_annotation(&arg.field_type),
})
.collect(),
},
}
}
fn invocation_model_replace_type_name(model_name: &str) -> String {
format!("Replace{model_name}")
}
pub(in crate::generator) fn generic_model_annotation(
model_name: &str,
type_parameters: &[RenderedTypeParameter],
) -> String {
if type_parameters.is_empty() {
model_name.to_string()
} else {
format!(
"{}<{}>",
model_name,
type_parameters
.iter()
.map(|type_parameter| type_parameter.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
}
fn generic_type_annotation(model_name: &str, type_parameters: &[String]) -> String {
if type_parameters.is_empty() {
model_name.to_string()
} else {
format!("{}<{}>", model_name, type_parameters.join(", "))
}
}
fn typescript_erased_model_annotation(
model_name: &str,
parameters: &[RenderedTypeParameter],
) -> String {
let parameter_count = parameters
.iter()
.filter(|parameter| parameter.explicit)
.count();
if parameter_count == 0 {
model_name.to_string()
} else {
format!(
"{}<{}>",
model_name,
std::iter::repeat_n("any", parameter_count)
.collect::<Vec<_>>()
.join(", ")
)
}
}
fn typescript_erased_record_annotation(
model_name: &str,
parameters: &[crate::spec::TypeParameterUsage],
) -> String {
if parameters.is_empty() {
model_name.to_string()
} else {
format!(
"{}<{}>",
model_name,
std::iter::repeat_n("any", parameters.len())
.collect::<Vec<_>>()
.join(", ")
)
}
}
fn typescript_operation_output_annotation(
model_name: &str,
output_parameters: &[crate::spec::TypeParameterUsage],
input: Option<&PlannedType>,
api_plan: &PlannedSpec,
) -> String {
if output_parameters.is_empty() {
return model_name.to_string();
}
let input_parameters = input
.map(|input| api_plan.type_parameters(input, Language::TypeScript))
.unwrap_or_default()
.into_iter()
.map(|usage| (usage.parameter.full_name, usage.parameter.name))
.collect::<BTreeMap<_, _>>();
let arguments = output_parameters
.iter()
.map(|usage| {
input_parameters
.get(&usage.parameter.full_name)
.map(String::as_str)
.unwrap_or("unknown")
})
.collect::<Vec<_>>()
.join(", ");
format!("{model_name}<{arguments}>")
}
#[derive(Debug)]
struct RenderedService<'a> {
name: &'a str,
attr_name: String,
wire_name: &'a str,
doc: Option<String>,
endpoint: Option<String>,
experimental: bool,
deprecated: bool,
operations: Vec<RenderedOperation<'a>>,
resources: Vec<PlannedResource>,
}
#[derive(Debug)]
struct RenderedOperation<'a> {
name: &'a str,
wire_name: &'a str,
attr_name: String,
experimental: bool,
deprecated: bool,
doc: Option<String>,
return_doc: Option<String>,
output_operation_annotation: String,
service_output_operation_annotation: String,
output_type_id: String,
input: Option<RenderedOperationInput>,
output_annotation: String,
output_transform_expr: Option<String>,
output_resource_return: Option<PlannedOperationResourceReturn>,
output_direct_result: bool,
output_model_name: Option<String>,
output_transfer_type_converter: Option<String>,
}
#[derive(Debug)]
struct RenderedOperationInput {
operation_annotation: String,
type_id: String,
model_name: Option<String>,
type_parameters: Vec<RenderedTypeParameter>,
annotation: String,
api_omitted_fields: Vec<String>,
to_wire_expr: String,
transfer_type_converter: Option<String>,
}
#[derive(Debug)]
struct RenderedEnum {
name: String,
values: Vec<RenderedEnumValue>,
}
#[derive(Debug)]
struct RenderedEnumValue {
name: String,
number: i32,
}
#[derive(Debug)]
struct RenderedFlags {
name: String,
flags: Vec<RenderedFlag>,
}
#[derive(Debug)]
struct RenderedFlag {
name: String,
bit: usize,
}
#[derive(Debug)]
struct RenderedVariant {
name: String,
type_parameters: Vec<String>,
cases: Vec<RenderedVariantCase>,
}
#[derive(Debug)]
struct RenderedVariantCase {
name: String,
payload_annotation: Option<String>,
requirements: TypeScriptRequirements,
}
#[derive(Debug)]
pub(in crate::generator) struct RenderedModel {
pub(in crate::generator) full_name: String,
pub(in crate::generator) name: String,
pub(in crate::generator) wire_function_names: Option<WireFunctionNames>,
experimental: bool,
pub(in crate::generator) type_parameters: Vec<RenderedTypeParameter>,
pub(in crate::generator) fields: Vec<RenderedField>,
pub(in crate::generator) sourced_fields: Vec<RenderedSourcedField>,
functions: Vec<RenderedFunctionField>,
with_arguments: Vec<RenderedWithArgumentsField>,
}
#[derive(Debug, Clone)]
pub(in crate::generator) struct WireFunctionNames {
pub(in crate::generator) from_wire: String,
pub(in crate::generator) to_wire: String,
}
#[derive(Debug)]
pub(in crate::generator) struct RenderedField {
pub(in crate::generator) name: String,
pub(in crate::generator) wire_name: String,
annotation: String,
doc: Option<String>,
optional: bool,
pub(in crate::generator) from_wire_expr: String,
pub(in crate::generator) to_wire_expr: String,
pub(in crate::generator) flattened_fields: Vec<RenderedFlattenedField>,
requirements: TypeScriptRequirements,
}
#[derive(Debug)]
pub(in crate::generator) struct RenderedFlattenedField {
pub(in crate::generator) name: String,
wire_name: String,
annotation: String,
doc: Option<String>,
optional: bool,
pub(in crate::generator) from_wire_expr: String,
to_wire_expr: String,
}
#[derive(Debug)]
pub(in crate::generator) struct RenderedSourcedField {
pub(in crate::generator) name: String,
pub(in crate::generator) to_wire_expr: String,
}
#[derive(Debug, Clone)]
pub(in crate::generator) struct RenderedTypeParameter {
name: String,
constraint: String,
default: String,
infer_from_operation_request: bool,
explicit: bool,
}
#[derive(Debug, Clone)]
struct RenderedFunctionField {
callable_field_name: String,
args_field_name: String,
primary: bool,
args: RenderedFunctionArgs,
type_parameter_name: String,
alternate_annotation: Option<String>,
}
#[derive(Debug, Clone)]
enum RenderedFunctionArgs {
Varargs {
prefix: Vec<RenderedFunctionArg>,
typescript_drop_prefix: bool,
},
Typed {
parameters: Vec<RenderedFunctionArg>,
},
}
#[derive(Debug, Clone)]
struct RenderedFunctionArg {
name: String,
annotation: String,
}
#[derive(Debug, Clone)]
struct RenderedWithArgumentsField {
value_field_name: String,
args_field_name: String,
type_parameter_name: String,
args_type_parameter_name: String,
alternate_annotation: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub(in crate::generator) struct TypeScriptRequirements {
pub(in crate::generator) long: bool,
}
impl TypeScriptRequirements {
fn merge(&mut self, other: &TypeScriptRequirements) {
self.long |= other.long;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::generator) enum ResolvedFieldKind {
Scalar,
Message,
Enum,
}
#[derive(Debug, Clone)]
pub(in crate::generator) struct ResolvedFieldType {
pub(in crate::generator) annotation: String,
pub(in crate::generator) kind: ResolvedFieldKind,
pub(in crate::generator) requirements: TypeScriptRequirements,
pub(in crate::generator) wire_conversion: Option<WireValueConversion>,
}
#[derive(Debug, Clone)]
pub(in crate::generator) struct WireValueConversion {
pub(in crate::generator) annotation: String,
pub(in crate::generator) from_wire: String,
pub(in crate::generator) to_wire: String,
pub(in crate::generator) function_name_to_wire: Option<String>,
pub(in crate::generator) wire_function_names: Option<WireFunctionNames>,
pub(in crate::generator) uses_rendered_model_annotation: bool,
}
impl WireValueConversion {
pub(in crate::generator) fn from_wire_expr(&self, wire_expr: &str) -> String {
self.from_wire.replace("{wire}", wire_expr)
}
pub(in crate::generator) fn to_wire_expr(&self, value_expr: &str) -> String {
self.to_wire.replace("{value}", value_expr)
}
pub(in crate::generator) fn function_name_to_wire_expr(&self, name_expr: &str) -> String {
self.function_name_to_wire
.as_ref()
.map(|template| template.replace("{name}", name_expr))
.unwrap_or_else(|| name_expr.to_string())
}
fn uses_rendered_model_annotation(&self) -> bool {
self.uses_rendered_model_annotation
}
}
#[derive(Debug, Default)]
struct SupportExports {
value_names: Vec<String>,
type_names: Vec<String>,
}
fn collect_typescript_requirements(
variants: &[&RenderedVariant],
models: &[&RenderedModel],
services: &[RenderedService<'_>],
) -> TypeScriptRequirements {
let mut requirements = TypeScriptRequirements::default();
for variant in variants {
for case in &variant.cases {
requirements.merge(&case.requirements);
}
}
for model in models {
for field in &model.fields {
requirements.merge(&field.requirements);
}
}
for service in services {
for resource in &service.resources {
for field in &resource.fields {
requirements.merge(&typescript_resource_field_requirements(&field.kind));
}
for method in &resource.methods {
for param in &method.params {
requirements.merge(&typescript_resource_field_requirements(¶m.kind));
}
if let Some(result) = &method.result
&& let PlannedResourceMethodResultKind::Value(kind) = &result.kind
{
requirements.merge(&typescript_resource_field_requirements(kind));
}
}
}
}
requirements
}
#[allow(clippy::too_many_arguments)]
fn render_module_files(
enums: &[&RenderedEnum],
flags: &[&RenderedFlags],
variants: &[&RenderedVariant],
models: &[&RenderedModel],
external_models: &TypeScriptExternalModels,
model_fragments: &RenderedExternalModelFragments,
services: &[RenderedService<'_>],
requirements: &TypeScriptRequirements,
language_imports: &[LanguageImportSpec],
support_source: Option<&str>,
api_plan: &PlannedSpec,
mode: GenerationMode,
) -> Result<GeneratedFiles> {
let support_source = support_source.filter(|source| !source.trim().is_empty());
let support_exports = support_source.map(support_exports);
let module_model_names = model_fragments
.type_exported_names
.iter()
.cloned()
.collect();
let json_runtime_files = external_models.render_support_files()?;
let has_json_runtime_module = json_runtime_files.contains_key(&PathBuf::from("definitions.ts"));
let mut files = BTreeMap::<PathBuf, String>::new();
let models_source = render_models_module(
enums,
flags,
variants,
models,
external_models,
model_fragments,
language_imports,
support_exports.as_ref(),
api_plan,
mode,
);
let has_models_module = !is_blank_generated_module(&models_source);
files.insert(
"index.ts".into(),
if mode == GenerationMode::NativeApi {
render_index_module(services, &model_fragments.type_exported_names)
} else {
render_definitions_only_index_module(
services,
has_json_runtime_module,
has_models_module,
)
},
);
if has_models_module {
files.insert("models.ts".into(), models_source);
}
if !services.is_empty() {
files.insert(
"services.ts".into(),
render_service_module(
enums,
flags,
variants,
models,
&module_model_names,
services,
requirements,
language_imports,
mode == GenerationMode::NativeApi,
api_plan,
),
);
}
if services.iter().any(|service| !service.resources.is_empty()) {
files.insert(
"resources.ts".into(),
render_resources_module(
enums,
flags,
variants,
models,
&module_model_names,
services,
requirements,
language_imports,
support_exports.as_ref(),
api_plan,
),
);
}
if mode == GenerationMode::NativeApi {
for service in services {
if service.endpoint.is_none() {
continue;
}
for operation in &service.operations {
files.insert(
format!("operations/{}.ts", operation_file_name(operation)).into(),
render_operation_module(
enums,
flags,
variants,
models,
&module_model_names,
services,
service,
operation,
requirements,
language_imports,
support_exports.as_ref(),
api_plan,
),
);
}
}
}
if let Some(support_source) = support_source {
files.insert("support.ts".into(), render_support_module(support_source));
}
files.extend(json_runtime_files);
Ok(GeneratedFiles::directory(files))
}
fn support_source(support_fragments: &[SupportFragmentSpec]) -> Option<String> {
(!support_fragments.is_empty()).then(|| {
support_fragments
.iter()
.map(|fragment| fragment.contents.as_str())
.collect::<Vec<_>>()
.join("\n\n")
})
}
fn operation_file_name(operation: &RenderedOperation<'_>) -> String {
operation.attr_name.to_kebab_case()
}
fn support_exports(source: &str) -> SupportExports {
let mut exports = SupportExports::default();
for line in source.lines().map(str::trim_start) {
if let Some(name) = export_name(line, "export function ") {
exports.value_names.push(name.to_string());
} else if let Some(name) = export_name(line, "export const ") {
exports.value_names.push(name.to_string());
} else if let Some(name) = export_name(line, "export let ") {
exports.value_names.push(name.to_string());
} else if let Some(name) = export_name(line, "export var ") {
exports.value_names.push(name.to_string());
} else if let Some(name) = export_name(line, "export class ") {
exports.value_names.push(name.to_string());
} else if let Some(name) = export_name(line, "export enum ") {
exports.value_names.push(name.to_string());
} else if let Some(name) = export_name(line, "export type ") {
exports.type_names.push(name.to_string());
} else if let Some(name) = export_name(line, "export interface ") {
exports.type_names.push(name.to_string());
}
}
exports
}
fn export_name<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
let remainder = line.strip_prefix(prefix)?;
let end = remainder
.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '$'))
.unwrap_or(remainder.len());
(end > 0).then_some(&remainder[..end])
}
fn render_typescript_namespace_imports(
output: &mut String,
source: &str,
language_imports: &[LanguageImportSpec],
generated_value_imports: &[(&str, &str)],
) {
let mut namespace_imports = BTreeMap::<(String, String), bool>::new();
let mut named_type_imports = BTreeMap::<String, BTreeSet<String>>::new();
for (namespace, package) in generated_value_imports {
if contains_qualified_identifier(source, namespace) {
namespace_imports.insert(((*namespace).to_string(), (*package).to_string()), true);
}
}
for import in language_imports {
if !contains_qualified_identifier(source, &import.reference) {
continue;
}
match import.import_style {
LanguageImportStyle::Namespace => {
let key = (import.reference.clone(), import.module.clone());
let needs_value_import = !import.type_only;
namespace_imports
.entry(key)
.and_modify(|existing| *existing |= needs_value_import)
.or_insert(needs_value_import);
}
LanguageImportStyle::Named => {
named_type_imports
.entry(import.module.clone())
.or_default()
.insert(
import
.name
.clone()
.unwrap_or_else(|| import.reference.clone()),
);
}
LanguageImportStyle::Module => {}
}
}
for ((namespace, package), needs_value_import) in namespace_imports {
if needs_value_import {
output.push_str("import * as ");
} else {
output.push_str("import type * as ");
}
output.push_str(&namespace);
output.push_str(" from '");
output.push_str(&package);
output.push_str("';\n");
}
for (package, imports) in named_type_imports {
output.push_str("import type { ");
output.push_str(&imports.into_iter().collect::<Vec<_>>().join(", "));
output.push_str(" } from '");
output.push_str(&package);
output.push_str("';\n");
}
}
fn render_typescript_default_type_import_if_used(
output: &mut String,
source: &str,
name: &str,
package: &str,
) {
if !contains_identifier(source, name) {
return;
}
output.push_str("import type ");
output.push_str(name);
output.push_str(" from '");
output.push_str(package);
output.push_str("';\n");
}
fn contains_identifier(source: &str, identifier: &str) -> bool {
source.match_indices(identifier).any(|(index, _)| {
let before = source[..index].chars().next_back();
let after = source[index + identifier.len()..].chars().next();
!before.is_some_and(is_typescript_identifier_char)
&& !after.is_some_and(is_typescript_identifier_char)
})
}
fn contains_qualified_identifier(source: &str, identifier: &str) -> bool {
let chars = source.char_indices().collect::<Vec<_>>();
let mut index = 0;
while index < chars.len() {
let (start_byte, ch) = chars[index];
match ch {
'\'' | '"' | '`' => {
index = skip_typescript_string(&chars, index, ch);
continue;
}
'/' if chars.get(index + 1).is_some_and(|(_, next)| *next == '/') => {
index += 2;
while index < chars.len() && chars[index].1 != '\n' {
index += 1;
}
continue;
}
'/' if chars.get(index + 1).is_some_and(|(_, next)| *next == '*') => {
index += 2;
while index + 1 < chars.len() {
if chars[index].1 == '*' && chars[index + 1].1 == '/' {
index += 2;
break;
}
index += 1;
}
continue;
}
_ => {}
}
if !is_typescript_identifier_start(ch) {
index += 1;
continue;
}
let before = source[..start_byte].chars().next_back();
if before.is_some_and(|before| is_typescript_identifier_char(before) || before == '.') {
index += 1;
continue;
}
let mut end = index + 1;
while end < chars.len() && is_typescript_identifier_char(chars[end].1) {
end += 1;
}
let end_byte = chars
.get(end)
.map(|(byte, _)| *byte)
.unwrap_or(source.len());
if &source[start_byte..end_byte] == identifier {
let mut after = end;
while after < chars.len() && chars[after].1.is_whitespace() {
after += 1;
}
if after < chars.len() && chars[after].1 == '.' {
return true;
}
}
index = end;
}
false
}
fn skip_typescript_string(chars: &[(usize, char)], start: usize, quote: char) -> usize {
let mut index = start + 1;
let mut escaped = false;
while index < chars.len() {
let ch = chars[index].1;
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == quote {
return index + 1;
}
index += 1;
}
index
}
fn is_typescript_identifier_start(ch: char) -> bool {
ch.is_ascii_alphabetic() || ch == '_' || ch == '$'
}
fn is_typescript_identifier_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '$'
}
fn used_import_names(source: &str, names: &[String]) -> Vec<String> {
names
.iter()
.filter(|name| contains_identifier(source, name))
.cloned()
.collect()
}
fn render_generated_module(imports: String, body: String) -> String {
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str("\n\n");
if !imports.is_empty() {
output.push_str(&imports);
output.push('\n');
}
output.push_str(&body);
if !body.ends_with('\n') {
output.push('\n');
}
output
}
fn is_blank_generated_module(source: &str) -> bool {
source
.strip_prefix(GENERATED_HEADER)
.unwrap_or(source)
.trim()
.is_empty()
}
fn render_definitions_only_index_module(
services: &[RenderedService<'_>],
has_json_runtime_module: bool,
has_models_module: bool,
) -> String {
let mut output = String::new();
output.push_str(GENERATED_HEADER);
output.push_str("\n\n");
if !services.is_empty() {
output.push_str("export * from './services';\n");
}
if has_models_module {
output.push_str("export * from './models';\n");
}
if services.iter().any(|service| !service.resources.is_empty()) {
output.push_str("export * from './resources';\n");
}
if has_json_runtime_module {
output.push_str("export { ValidationError } from './definitions';\n");
output.push_str("export type { Violation } from './definitions';\n");
}
output
}
fn render_support_module(support_source: &str) -> String {
render_generated_module(String::new(), support_source.to_string())
}
fn render_index_module(
services: &[RenderedService<'_>],
model_type_names: &BTreeSet<String>,
) -> String {
let mut body = String::new();
for service in services {
if service.endpoint.is_some() {
for operation in &service.operations {
body.push_str("export { ");
body.push_str(&operation.attr_name);
body.push_str(" } from './operations/");
body.push_str(&operation_file_name(operation));
body.push_str("';\n");
}
} else {
body.push_str("export { ");
body.push_str(&service.attr_name);
body.push_str(", ");
body.push_str(&endpoint_service_class_name(service));
body.push_str(" } from './services';\n");
}
}
let mut model_names = services
.iter()
.flat_map(|service| {
service.operations.iter().flat_map(|operation| {
let output_model_name = service
.endpoint
.is_none()
.then(|| operation.output_model_name.as_deref())
.flatten();
operation
.input
.as_ref()
.and_then(|input| input.model_name.as_deref())
.into_iter()
.chain(output_model_name)
})
})
.collect::<Vec<_>>();
model_names.sort();
model_names.dedup();
if !model_names.is_empty() {
body.push_str("export type { ");
body.push_str(&model_names.join(", "));
body.push_str(" } from './models';\n");
} else if services.is_empty() && !model_type_names.is_empty() {
body.push_str("export type { ");
body.push_str(
&model_type_names
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", "),
);
body.push_str(" } from './models';\n");
}
render_generated_module(String::new(), body)
}
fn render_endpoint_service_class(output: &mut String, service: &RenderedService<'_>) {
let doc_tags = declaration_doc_tags(service.experimental, service.deprecated);
render_typescript_doc_comment(output, "", service.doc.as_deref(), &doc_tags);
output.push_str("export class ");
output.push_str(&endpoint_service_class_name(service));
output.push_str(" {\n");
output.push_str(" private readonly client: workflow.NexusServiceClient<typeof ");
output.push_str(&service.attr_name);
output.push_str(">;\n\n");
output.push_str(" public constructor(endpoint: string) {\n");
output.push_str(" this.client = workflow.createNexusServiceClient({\n");
output.push_str(" service: ");
output.push_str(&service.attr_name);
output.push_str(",\n");
output.push_str(" endpoint,\n");
output.push_str(" });\n");
output.push_str(" }\n");
for operation in &service.operations {
output.push('\n');
render_endpoint_service_operation_method(output, service, operation);
}
output.push_str("}\n");
}
fn endpoint_service_class_name(service: &RenderedService<'_>) -> String {
format!("{}Client", service.name)
}
fn render_endpoint_service_operation_method(
output: &mut String,
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
) {
let mut doc_tags = Vec::new();
if operation.input.is_some() {
doc_tags.push((
"@param request -".to_string(),
"Request for the operation.".to_string(),
));
}
if let Some(return_doc) = &operation.return_doc {
doc_tags.push(("@returns".to_string(), return_doc.clone()));
}
if operation.experimental {
doc_tags.push((
"@experimental".to_string(),
EXPERIMENTAL_WARNING.to_string(),
));
}
if operation.deprecated {
doc_tags.push(("@deprecated".to_string(), String::new()));
}
render_typescript_doc_comment(output, " ", operation.doc.as_deref(), &doc_tags);
output.push_str(" public async ");
output.push_str(&operation.attr_name);
output.push_str("(\n");
if let Some(input) = &operation.input {
output.push_str(" request: ");
output.push_str(&input.annotation);
output.push_str(",\n");
}
output.push_str(" ): Promise<");
output.push_str(&typescript_operation_public_return_annotation(
service, operation,
));
output.push_str("> {\n");
render_endpoint_service_operation_body(output, service, operation);
output.push_str(" }\n");
}
fn render_endpoint_service_operation_body(
output: &mut String,
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
) {
let returns_direct = operation.output_transform_expr.is_some()
|| operation.output_resource_return.is_some()
|| operation.output_direct_result;
if !returns_direct {
output.push_str(" return await this.client.startOperation(\n");
output.push_str(" ");
output.push_str(&service.attr_name);
output.push_str(".operations.");
output.push_str(&operation.attr_name);
output.push_str(",\n");
output.push_str(" ");
output.push_str(&typescript_operation_input_expr(operation));
output.push_str(",\n");
output.push_str(" );\n");
return;
}
output.push_str(" const requestProto = ");
output.push_str(&typescript_operation_input_expr(operation));
output.push_str(";\n");
output.push_str(" const handle = await this.client.startOperation(\n");
output.push_str(" ");
output.push_str(&service.attr_name);
output.push_str(".operations.");
output.push_str(&operation.attr_name);
output.push_str(",\n");
output.push_str(" requestProto,\n");
output.push_str(" );\n");
if let Some(transform_expr) = &operation.output_transform_expr {
output.push_str(" const result = await handle.result();\n");
output.push_str(" return ");
output.push_str(transform_expr);
output.push_str(";\n");
} else if let Some(resource_return) = &operation.output_resource_return {
output.push_str(" const result = await handle.result();\n");
output.push_str(" return ");
output.push_str(&resource_client_bind_function_name_for_type(
&resource_return.resource_type_name,
));
output.push_str("(\n");
output.push_str(" new ");
output.push_str(&resource_return.resource_type_name);
output.push_str("(\n");
for binding in &resource_return.bindings {
output.push_str(" ");
output.push_str(&resource_return_binding_expr_typescript(binding));
output.push_str(",\n");
}
output.push_str(" ),\n");
output.push_str(" this.client,\n");
output.push_str(" );\n");
} else if operation.output_direct_result {
if let Some(resource) = service
.resources
.iter()
.find(|resource| resource.type_name == operation.output_annotation)
{
output.push_str(" const resource = await handle.result();\n");
output.push_str(" return ");
output.push_str(&resource_client_bind_function_name(resource));
output.push_str("(resource, this.client);\n");
} else {
output.push_str(" return await handle.result();\n");
}
}
}
fn render_operation_registry_module(services: &[RenderedService<'_>]) -> String {
let mut body = String::new();
body.push_str("export const operationRegistry = [\n");
for service in services {
for operation in &service.operations {
body.push_str(" {\n");
body.push_str(" service: ");
body.push_str(&typescript_string_literal(service.wire_name));
body.push_str(",\n");
body.push_str(" operation: ");
body.push_str(&typescript_string_literal(operation.wire_name));
body.push_str(",\n");
body.push_str(" inputType: ");
body.push_str(&typescript_string_literal(
operation
.input
.as_ref()
.map(|input| input.type_id.as_str())
.unwrap_or("void"),
));
body.push_str(",\n");
body.push_str(" outputType: ");
body.push_str(&typescript_string_literal(&operation.output_type_id));
body.push_str(",\n");
body.push_str(" },\n");
}
}
body.push_str("] as const;\n");
body
}
fn render_models_module(
enums: &[&RenderedEnum],
flags: &[&RenderedFlags],
variants: &[&RenderedVariant],
models: &[&RenderedModel],
external_models: &TypeScriptExternalModels,
model_fragments: &RenderedExternalModelFragments,
language_imports: &[LanguageImportSpec],
support_exports: Option<&SupportExports>,
api_plan: &PlannedSpec,
mode: GenerationMode,
) -> String {
let mut body = String::new();
if mode == GenerationMode::NativeApi {
render_required_field(&mut body, true);
}
let uses_invocation_models = models
.iter()
.any(|model| !model.functions.is_empty() || !model.with_arguments.is_empty());
let uses_configured_payload_converter = uses_invocation_models
|| models
.iter()
.any(|model| model_uses_configured_payload_converter(model));
if uses_configured_payload_converter {
body.push('\n');
render_configured_payload_converter(&mut body);
}
if uses_invocation_models {
body.push('\n');
render_function_runtime_helpers(
&mut body,
models.iter().any(|model| {
model.fields.iter().any(|field| {
field.from_wire_expr.contains("requestArgsFromPayloads(")
|| field
.flattened_fields
.iter()
.any(|field| field.from_wire_expr.contains("requestArgsFromPayloads("))
})
}),
);
}
if !enums.is_empty() {
body.push('\n');
for enumeration in enums {
render_enum(&mut body, enumeration);
body.push('\n');
}
}
if !flags.is_empty() {
body.push('\n');
for flag_set in flags {
render_flags(&mut body, flag_set);
body.push('\n');
}
}
if !variants.is_empty() {
body.push('\n');
for variant in variants {
render_variant(&mut body, variant);
body.push('\n');
}
}
if !models.is_empty() {
body.push('\n');
for model in models {
render_model(&mut body, model, external_models, api_plan);
body.push('\n');
}
}
if !model_fragments.body.is_empty() {
body.push('\n');
body.push_str(&model_fragments.body);
}
let mut imports = String::new();
let generated_value_imports = if uses_configured_payload_converter {
vec![("common", "@temporalio/common")]
} else {
Vec::new()
};
let mut model_language_imports = language_imports.to_vec();
if uses_invocation_models {
model_language_imports.push(LanguageImportSpec {
language: Language::TypeScript,
reference: "temporal".to_string(),
module: "@temporalio/proto".to_string(),
name: Some("temporal".to_string()),
type_only: true,
import_style: LanguageImportStyle::Named,
});
}
render_typescript_namespace_imports(
&mut imports,
&body,
&model_language_imports,
&generated_value_imports,
);
render_typescript_default_type_import_if_used(&mut imports, &body, "Long", "long");
render_support_imports(&mut imports, support_exports, "./support", &body);
if !model_fragments.imports.is_empty() {
if !imports.is_empty() && !imports.ends_with('\n') {
imports.push('\n');
}
imports.push_str(&model_fragments.imports);
}
render_cross_module_model_value_imports(
&mut imports,
&api_plan.module_path.to_path_buf(),
api_plan,
&body,
);
render_cross_module_model_type_imports(
&mut imports,
&api_plan.module_path.to_path_buf(),
api_plan,
&body,
);
render_generated_module(imports, body)
}
fn typescript_relative_models_module_from_dir(from_dir: &Path, to: &ModulePath) -> String {
let target = to.to_path_buf().join("models");
relative_module_path(from_dir, &target)
}
fn render_cross_module_model_type_imports(
output: &mut String,
source_dir: &Path,
api_plan: &PlannedSpec,
source: &str,
) {
for (module_path, names) in &api_plan.data.module_imports {
let candidates = names.iter().cloned().collect::<Vec<_>>();
let used_names = used_import_names(source, &candidates);
if !used_names.is_empty() {
render_type_imports(
output,
&typescript_relative_models_module_from_dir(source_dir, module_path),
&used_names,
);
}
}
}
fn render_cross_module_model_value_imports(
output: &mut String,
source_dir: &Path,
api_plan: &PlannedSpec,
source: &str,
) {
for (module_path, names) in &api_plan.data.module_imports {
let candidates = names
.iter()
.map(|name| typescript_json::ts_transfer_type_converter_name(name))
.collect::<Vec<_>>();
let used_names = used_import_names(source, &candidates);
if !used_names.is_empty() {
render_value_imports(
output,
&typescript_relative_models_module_from_dir(source_dir, module_path),
&used_names,
);
}
}
}
fn relative_module_path(from_dir: &std::path::Path, target: &std::path::Path) -> String {
let from = from_dir.components().collect::<Vec<_>>();
let to = target.components().collect::<Vec<_>>();
let common = from
.iter()
.zip(to.iter())
.take_while(|(left, right)| left == right)
.count();
let mut parts = Vec::new();
for _ in common..from.len() {
parts.push("..".to_string());
}
for component in &to[common..] {
parts.push(component.as_os_str().to_string_lossy().to_string());
}
let path = parts.join("/");
if path.starts_with('.') {
path
} else {
format!("./{path}")
}
}
#[allow(clippy::too_many_arguments)]
fn render_service_module(
enums: &[&RenderedEnum],
flags: &[&RenderedFlags],
variants: &[&RenderedVariant],
models: &[&RenderedModel],
external_model_names: &BTreeSet<String>,
services: &[RenderedService<'_>],
_requirements: &TypeScriptRequirements,
language_imports: &[LanguageImportSpec],
include_native_api: bool,
api_plan: &PlannedSpec,
) -> String {
let mut body = String::new();
for service in services {
render_service_definition(&mut body, service);
body.push('\n');
if include_native_api && service.endpoint.is_none() {
render_endpoint_service_class(&mut body, service);
body.push('\n');
}
}
if include_native_api {
body.push_str(&render_operation_registry_module(services));
}
let mut imports = String::new();
render_typescript_namespace_imports(
&mut imports,
&body,
language_imports,
&[("nexus", "nexus-rpc"), ("workflow", "@temporalio/workflow")],
);
render_typescript_default_type_import_if_used(&mut imports, &body, "Long", "long");
render_value_imports(
&mut imports,
"./models",
&used_import_names(
&body,
&external_model_names
.iter()
.map(|name| typescript_json::ts_transfer_type_converter_name(name))
.collect::<Vec<_>>(),
),
);
render_type_imports(
&mut imports,
"./models",
&used_import_names(
&body,
&model_type_names(enums, flags, variants, models, external_model_names),
),
);
render_cross_module_model_value_imports(
&mut imports,
&api_plan.module_path.to_path_buf(),
api_plan,
&body,
);
render_cross_module_model_type_imports(
&mut imports,
&api_plan.module_path.to_path_buf(),
api_plan,
&body,
);
render_type_imports(
&mut imports,
"./resources",
&used_import_names(&body, &resource_type_names(services)),
);
let resource_client_binders = services
.iter()
.filter(|service| include_native_api && service.endpoint.is_none())
.flat_map(|service| service.resources.iter())
.filter(|resource| {
contains_identifier(&body, &resource_client_bind_function_name(resource))
})
.map(resource_client_bind_function_name)
.collect::<Vec<_>>();
render_value_imports(&mut imports, "./resources", &resource_client_binders);
render_generated_module(imports, body)
}
#[allow(clippy::too_many_arguments)]
fn render_resources_module(
enums: &[&RenderedEnum],
flags: &[&RenderedFlags],
variants: &[&RenderedVariant],
models: &[&RenderedModel],
external_model_names: &BTreeSet<String>,
services: &[RenderedService<'_>],
_requirements: &TypeScriptRequirements,
language_imports: &[LanguageImportSpec],
support_exports: Option<&SupportExports>,
api_plan: &PlannedSpec,
) -> String {
let mut body = String::new();
for service in services {
for resource in &service.resources {
render_resource(&mut body, resource, service);
body.push('\n');
}
}
let mut imports = String::new();
render_typescript_namespace_imports(
&mut imports,
&body,
language_imports,
&[("workflow", "@temporalio/workflow")],
);
render_typescript_default_type_import_if_used(&mut imports, &body, "Long", "long");
render_type_imports(
&mut imports,
"./models",
&used_import_names(
&body,
&model_type_names(enums, flags, variants, models, external_model_names),
),
);
render_cross_module_model_type_imports(
&mut imports,
&api_plan.module_path.to_path_buf(),
api_plan,
&body,
);
render_support_imports(&mut imports, support_exports, "./support", &body);
for service in services {
if contains_identifier(&body, &service.attr_name) {
render_value_imports(
&mut imports,
"./services",
std::slice::from_ref(&service.attr_name),
);
}
if service.endpoint.is_some() {
for operation in &service.operations {
let operation_function_name =
typescript_operation_function_name(service, operation);
if contains_identifier(&body, &operation_function_name) {
render_value_imports(
&mut imports,
&format!("./operations/{}", operation_file_name(operation)),
std::slice::from_ref(&operation_function_name),
);
}
}
}
}
render_generated_module(imports, body)
}
fn render_operation_module(
enums: &[&RenderedEnum],
flags: &[&RenderedFlags],
variants: &[&RenderedVariant],
models: &[&RenderedModel],
external_model_names: &BTreeSet<String>,
services: &[RenderedService<'_>],
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
_requirements: &TypeScriptRequirements,
language_imports: &[LanguageImportSpec],
support_exports: Option<&SupportExports>,
api_plan: &PlannedSpec,
) -> String {
let mut body = String::new();
render_operation_input_alias(&mut body, service, operation);
render_operation_function(&mut body, service, operation);
let mut imports = String::new();
render_typescript_namespace_imports(
&mut imports,
&body,
language_imports,
&[("workflow", "@temporalio/workflow")],
);
render_typescript_default_type_import_if_used(&mut imports, &body, "Long", "long");
render_value_imports(&mut imports, "../services", &[service.attr_name.clone()]);
let mut model_values = model_to_wire_function_names(models);
model_values.push("requiredField".to_string());
model_values.sort();
model_values.dedup();
render_value_imports(
&mut imports,
"../models",
&used_import_names(&body, &model_values),
);
render_type_imports(
&mut imports,
"../models",
&used_import_names(
&body,
&model_type_names(enums, flags, variants, models, external_model_names),
),
);
render_cross_module_model_type_imports(
&mut imports,
&api_plan.module_path.to_path_buf().join("operations"),
api_plan,
&body,
);
render_support_imports(&mut imports, support_exports, "../support", &body);
let resources = used_import_names(&body, &resource_type_names(services));
let value_resources = resources
.iter()
.filter(|resource| body.contains(&format!("new {resource}(")))
.cloned()
.collect::<Vec<_>>();
let type_resources = resources
.iter()
.filter(|resource| !value_resources.contains(resource))
.cloned()
.collect::<Vec<_>>();
let resource_client_binders = services
.iter()
.filter(|service| service.endpoint.is_none())
.flat_map(|service| service.resources.iter())
.filter(|resource| {
contains_identifier(&body, &resource_client_bind_function_name(resource))
})
.map(resource_client_bind_function_name)
.collect::<Vec<_>>();
if !type_resources.is_empty() {
imports.push_str("import '../resources';\n");
}
let mut resource_value_imports = value_resources;
resource_value_imports.extend(resource_client_binders);
render_value_imports(&mut imports, "../resources", &resource_value_imports);
render_type_imports(&mut imports, "../resources", &type_resources);
render_generated_module(imports, body)
}
fn render_support_imports(
output: &mut String,
support_exports: Option<&SupportExports>,
path: &str,
source: &str,
) {
if let Some(support_exports) = support_exports {
render_value_imports(
output,
path,
&used_import_names(source, &support_exports.value_names),
);
render_type_imports(
output,
path,
&used_import_names(source, &support_exports.type_names),
);
}
}
fn render_value_imports(output: &mut String, path: &str, names: &[String]) {
if names.is_empty() {
return;
}
output.push_str("import { ");
output.push_str(&names.join(", "));
output.push_str(" } from '");
output.push_str(path);
output.push_str("';\n");
}
fn render_type_imports(output: &mut String, path: &str, names: &[String]) {
if names.is_empty() {
return;
}
output.push_str("import type { ");
output.push_str(&names.join(", "));
output.push_str(" } from '");
output.push_str(path);
output.push_str("';\n");
}
fn model_type_names(
enums: &[&RenderedEnum],
flags: &[&RenderedFlags],
variants: &[&RenderedVariant],
models: &[&RenderedModel],
external_model_names: &BTreeSet<String>,
) -> Vec<String> {
enums
.iter()
.map(|enumeration| enumeration.name.clone())
.chain(flags.iter().map(|flags| flags.name.clone()))
.chain(variants.iter().map(|variant| variant.name.clone()))
.chain(models.iter().map(|model| model.name.clone()))
.chain(external_model_names.iter().cloned())
.collect()
}
fn resource_type_names(services: &[RenderedService<'_>]) -> Vec<String> {
services
.iter()
.flat_map(|service| {
service
.resources
.iter()
.map(|resource| resource.type_name.clone())
})
.collect()
}
fn model_to_wire_function_names(models: &[&RenderedModel]) -> Vec<String> {
models
.iter()
.filter_map(|model| {
model
.wire_function_names
.as_ref()
.map(|names| names.to_wire.clone())
})
.collect()
}
fn render_required_field(output: &mut String, exported: bool) {
if exported {
output.push_str("export ");
}
output.push_str("function requiredField<T>(\n");
output.push_str(" value: T | null | undefined,\n");
output.push_str(" owner: string,\n");
output.push_str(" field: string,\n");
output.push_str("): T {\n");
output.push_str(" if (value == null) {\n");
output.push_str(" throw new Error(`missing required field ${owner}.${field}`);\n");
output.push_str(" }\n");
output.push_str(" return value;\n");
output.push_str("}\n");
}
fn render_enum(output: &mut String, enumeration: &RenderedEnum) {
output.push_str("export enum ");
output.push_str(&enumeration.name);
output.push_str(" {\n");
if enumeration.values.is_empty() {
output.push_str("}\n");
return;
}
for value in &enumeration.values {
output.push_str(" ");
output.push_str(&value.name);
output.push_str(" = ");
output.push_str(&value.number.to_string());
output.push_str(",\n");
}
output.push_str("}\n");
}
fn render_flags(output: &mut String, flags: &RenderedFlags) {
output.push_str("export enum ");
output.push_str(&flags.name);
output.push_str(" {\n");
if flags.flags.is_empty() {
output.push_str("}\n");
return;
}
for flag in &flags.flags {
output.push_str(" ");
output.push_str(&flag.name);
output.push_str(" = 2 ** ");
output.push_str(&flag.bit.to_string());
output.push_str(",\n");
}
output.push_str("}\n");
}
fn render_variant(output: &mut String, variant: &RenderedVariant) {
output.push_str("export type ");
output.push_str(&variant.name);
if !variant.type_parameters.is_empty() {
output.push('<');
output.push_str(&variant.type_parameters.join(", "));
output.push('>');
}
output.push_str(" =\n");
if variant.cases.is_empty() {
output.push_str(" never;\n");
return;
}
for (index, case) in variant.cases.iter().enumerate() {
output.push_str(" | { tag: ");
output.push_str(&typescript_string_literal(&case.name));
if let Some(payload_annotation) = &case.payload_annotation {
output.push_str("; value: ");
output.push_str(payload_annotation);
}
output.push_str(" }");
if index + 1 == variant.cases.len() {
output.push_str(";\n");
} else {
output.push('\n');
}
}
}
fn render_function_runtime_helpers(output: &mut String, render_from: bool) {
if render_from {
output.push_str("function requestArgsFromPayloads(\n");
output.push_str(" payloads: temporal.api.common.v1.IPayloads | null | undefined,\n");
output.push_str("): unknown[] | undefined {\n");
output.push_str(" if (payloads == null) {\n");
output.push_str(" return undefined;\n");
output.push_str(" }\n");
output.push_str(
" return common.arrayFromPayloads(configuredPayloadConverter(), payloads.payloads);\n",
);
output.push_str("}\n\n");
}
output.push_str("function requestArgsToPayloads(\n");
output.push_str(" args: ReadonlyArray<unknown> | undefined,\n");
output.push_str("): temporal.api.common.v1.IPayloads | undefined {\n");
output.push_str(" if (args == null) {\n");
output.push_str(" return undefined;\n");
output.push_str(" }\n");
output
.push_str(" const payloads = common.toPayloads(configuredPayloadConverter(), ...args);\n");
output.push_str(" return payloads == null ? undefined : { payloads };\n");
output.push_str("}\n");
}
fn model_uses_configured_payload_converter(model: &RenderedModel) -> bool {
model.fields.iter().any(|field| {
field.to_wire_expr.contains("configuredPayloadConverter()")
|| field
.flattened_fields
.iter()
.any(|field| field.to_wire_expr.contains("configuredPayloadConverter()"))
}) || model
.sourced_fields
.iter()
.any(|field| field.to_wire_expr.contains("configuredPayloadConverter()"))
}
fn render_configured_payload_converter(output: &mut String) {
output.push_str("function configuredPayloadConverter(): common.PayloadConverter {\n");
output.push_str(" const activator = (\n");
output.push_str(" globalThis as typeof globalThis & {\n");
output.push_str(" __TEMPORAL_ACTIVATOR__?: {\n");
output.push_str(" payloadConverter?: common.PayloadConverter;\n");
output.push_str(" };\n");
output.push_str(" }\n");
output.push_str(" ).__TEMPORAL_ACTIVATOR__;\n");
output.push_str(" if (activator?.payloadConverter == null) {\n");
output.push_str(
" throw new Error('payload converter is unavailable outside workflow context');\n",
);
output.push_str(" }\n");
output.push_str(" return activator.payloadConverter;\n");
output.push_str("}\n");
}
fn render_type_parameter_list(type_parameters: &[RenderedTypeParameter]) -> String {
if type_parameters.is_empty() {
return String::new();
}
let params = type_parameters
.iter()
.map(|type_parameter| {
if type_parameter.explicit {
type_parameter.name.clone()
} else {
format!(
"{} extends {} = {}",
type_parameter.name, type_parameter.constraint, type_parameter.default
)
}
})
.collect::<Vec<_>>()
.join(", ");
format!("<{params}>")
}
pub(in crate::generator) fn render_named_generic_function_start(
output: &mut String,
name: &str,
type_parameters: &[RenderedTypeParameter],
indent: usize,
) {
let indent_str = " ".repeat(indent);
output.push_str(name);
output.push_str("<\n");
for type_parameter in type_parameters {
output.push_str(&indent_str);
output.push_str(" ");
output.push_str(&type_parameter.name);
if !type_parameter.explicit {
output.push_str(" extends ");
output.push_str(&type_parameter.constraint);
output.push_str(" = ");
output.push_str(&type_parameter.default);
}
output.push_str(",\n");
}
output.push_str(&indent_str);
output.push_str(">(\n");
}
fn render_function_model_invocations(output: &mut String, model: &RenderedModel) {
let mut invocations = model
.functions
.iter()
.filter(|function| matches!(function.args, RenderedFunctionArgs::Varargs { .. }))
.map(RenderedInvocationField::Function)
.chain(
model
.with_arguments
.iter()
.map(RenderedInvocationField::WithArguments),
)
.collect::<Vec<_>>();
invocations.sort_by(|left, right| {
left.sort_key()
.cmp(&right.sort_key())
.then_with(|| left.field_name().cmp(right.field_name()))
});
for (index, invocation) in invocations.into_iter().enumerate() {
if index > 0 {
output.push_str(" &\n");
}
render_invocation_union(output, model, invocation);
}
}
fn render_invocation_model_replace_helper(output: &mut String, model: &RenderedModel) {
let field_names = invocation_model_replaced_field_names(model);
if field_names.is_empty() {
return;
}
output.push_str("export type ");
output.push_str(&invocation_model_replace_type_name(&model.name));
output.push_str("<Base, New> = Omit<Base, ");
output.push_str(
&field_names
.iter()
.map(|field_name| typescript_string_literal(field_name))
.collect::<Vec<_>>()
.join(" | "),
);
output.push_str("> & New;\n\n");
}
fn invocation_model_replaced_field_names(model: &RenderedModel) -> Vec<String> {
let mut names = model
.functions
.iter()
.filter(|function| matches!(function.args, RenderedFunctionArgs::Varargs { .. }))
.flat_map(|function| {
[
function.callable_field_name.clone(),
function.args_field_name.clone(),
]
})
.chain(model.with_arguments.iter().flat_map(|with_arguments| {
[
with_arguments.value_field_name.clone(),
with_arguments.args_field_name.clone(),
]
}))
.collect::<Vec<_>>();
names.sort();
names.dedup();
names
}
fn model_uses_invocation_composition(model: &RenderedModel) -> bool {
model
.functions
.iter()
.any(|function| matches!(function.args, RenderedFunctionArgs::Varargs { .. }))
|| !model.with_arguments.is_empty()
}
fn render_invocation_model_base_type(output: &mut String, model: &RenderedModel) {
output.push_str("{\n");
for field in &model.fields {
render_invocation_model_base_property(output, model, field);
}
output.push('}');
}
fn render_invocation_model_base_property(
output: &mut String,
model: &RenderedModel,
field: &RenderedField,
) {
if !field.flattened_fields.is_empty() {
render_flattened_type_properties(output, " ", &field.flattened_fields);
return;
}
if let Some(function) = model
.functions
.iter()
.find(|function| function.callable_field_name == field.name)
{
render_typescript_type_property(
output,
" ",
&field.name,
field.optional,
&typescript_function_value_annotation(
function.alternate_annotation.as_deref(),
&function.type_parameter_name,
),
typescript_model_field_doc(model, field).as_deref(),
);
return;
}
if let Some(function) = model
.functions
.iter()
.find(|function| function.args_field_name == field.name)
{
render_typescript_type_property(
output,
" ",
&field.name,
field.optional,
&typescript_function_args_base_annotation(function),
typescript_model_field_doc(model, field).as_deref(),
);
return;
}
if let Some(with_arguments) = model
.with_arguments
.iter()
.find(|with_arguments| with_arguments.value_field_name == field.name)
{
render_typescript_type_property(
output,
" ",
&field.name,
field.optional,
&typescript_function_value_annotation(
with_arguments.alternate_annotation.as_deref(),
&with_arguments.type_parameter_name,
),
typescript_model_field_doc(model, field).as_deref(),
);
return;
}
if let Some(with_arguments) = model
.with_arguments
.iter()
.find(|with_arguments| with_arguments.args_field_name == field.name)
{
render_typescript_type_property(
output,
" ",
&field.name,
field.optional,
&format!(
"ReadonlyArray<unknown> | Readonly<{}>",
with_arguments.args_type_parameter_name
),
typescript_model_field_doc(model, field).as_deref(),
);
return;
}
render_typescript_type_property(
output,
" ",
&field.name,
field.optional,
&field.annotation,
typescript_model_field_doc(model, field).as_deref(),
);
}
fn typescript_function_value_annotation(
alternate_annotation: Option<&str>,
typed_annotation: &str,
) -> String {
if let Some(alternate_annotation) = alternate_annotation {
format!(
"{alternate_annotation} | {}",
parenthesized_typescript_union_member(typed_annotation)
)
} else {
typed_annotation.to_string()
}
}
fn typescript_function_args_base_annotation(function: &RenderedFunctionField) -> String {
let function_args = typescript_function_request_args_expression(function);
let parameters = format!("Readonly<{function_args}>");
match function.args {
RenderedFunctionArgs::Varargs { .. } => {
format!("ReadonlyArray<unknown> | {parameters}")
}
_ => parameters,
}
}
fn typescript_function_request_args_expression(function: &RenderedFunctionField) -> String {
match &function.args {
RenderedFunctionArgs::Varargs {
prefix,
typescript_drop_prefix: false,
} if !prefix.is_empty() => {
let prefix_pattern = vec!["any"; prefix.len()].join(", ");
format!(
"Parameters<{}> extends [{prefix_pattern}, ...infer Args] ? Args : never",
function.type_parameter_name
)
}
_ => format!("Parameters<{}>", function.type_parameter_name),
}
}
fn parenthesized_typescript_union_member(annotation: &str) -> String {
if annotation.starts_with('(') {
format!("({annotation})")
} else {
annotation.to_string()
}
}
fn render_invocation_union(
output: &mut String,
model: &RenderedModel,
invocation: RenderedInvocationField<'_>,
) {
match invocation {
RenderedInvocationField::Function(function) => {
let request_args = typescript_function_request_args_expression(function);
let request_args_union_member = parenthesized_typescript_type_expr(&request_args);
let args_annotation = format!("{request_args_union_member} | Readonly<{request_args}>");
let typed_args_condition = format!("({request_args})");
render_request_invocation_union(
output,
model,
&function.callable_field_name,
&function.args_field_name,
function.alternate_annotation.as_deref(),
"ReadonlyArray<unknown>",
&function.type_parameter_name,
&typed_args_condition,
&args_annotation,
);
}
RenderedInvocationField::WithArguments(with_arguments) => {
let args_annotation = format!(
"{} | Readonly<{}>",
with_arguments.args_type_parameter_name, with_arguments.args_type_parameter_name
);
render_request_invocation_union(
output,
model,
&with_arguments.value_field_name,
&with_arguments.args_field_name,
with_arguments.alternate_annotation.as_deref(),
"ReadonlyArray<unknown>",
&with_arguments.type_parameter_name,
&with_arguments.args_type_parameter_name,
&args_annotation,
);
}
}
}
fn render_request_invocation_union(
output: &mut String,
model: &RenderedModel,
value_field_name: &str,
args_field_name: &str,
alternate_value_annotation: Option<&str>,
alternate_args_annotation: &str,
typed_value_annotation: &str,
typed_args_condition: &str,
typed_args_annotation: &str,
) {
let value_doc = typescript_model_field_doc_by_name(model, value_field_name);
let args_doc = typescript_model_field_doc_by_name(model, args_field_name);
if let Some(alternate_value_annotation) = alternate_value_annotation {
output.push_str(" (\n");
output.push_str(" | {\n");
render_typescript_type_property(
output,
" ",
value_field_name,
false,
alternate_value_annotation,
value_doc.as_deref(),
);
render_typescript_type_property(
output,
" ",
args_field_name,
true,
alternate_args_annotation,
args_doc.as_deref(),
);
output.push_str(" }\n");
output.push_str(" | ({\n");
} else {
output.push_str(" {\n");
}
render_typescript_type_property(
output,
" ",
value_field_name,
false,
typed_value_annotation,
value_doc.as_deref(),
);
output.push_str(" } & (");
output.push_str(typed_args_condition);
output.push_str(" extends [any, ...any[]]\n");
output.push_str(" ? {\n");
render_typescript_type_property(
output,
" ",
args_field_name,
false,
typed_args_annotation,
args_doc.as_deref(),
);
output.push_str(" }\n");
output.push_str(" : {\n");
render_typescript_type_property(
output,
" ",
args_field_name,
true,
typed_args_annotation,
args_doc.as_deref(),
);
if alternate_value_annotation.is_some() {
output.push_str(" }))\n");
output.push_str(" )");
} else {
output.push_str(" })\n");
}
}
fn parenthesized_typescript_type_expr(expr: &str) -> String {
if expr.contains(" extends ") && expr.contains(" ? ") {
format!("({expr})")
} else {
expr.to_string()
}
}
#[derive(Clone, Copy)]
enum RenderedInvocationField<'a> {
Function(&'a RenderedFunctionField),
WithArguments(&'a RenderedWithArgumentsField),
}
impl<'a> RenderedInvocationField<'a> {
fn sort_key(self) -> (u8, bool) {
match self {
Self::Function(function) => (0, !function.primary),
Self::WithArguments(_) => (1, true),
}
}
fn field_name(self) -> &'a str {
match self {
Self::Function(function) => &function.callable_field_name,
Self::WithArguments(with_arguments) => &with_arguments.value_field_name,
}
}
}
fn with_arguments_args_type_expression(
args_type_template: &str,
type_parameter_name: &str,
) -> String {
args_type_template.replace("Value", type_parameter_name)
}
pub(in crate::generator) fn render_typescript_doc_comment(
output: &mut String,
indent: &str,
summary: Option<&str>,
tags: &[(String, String)],
) {
let has_summary = summary.is_some_and(|summary| !summary.trim().is_empty());
let has_tags = tags
.iter()
.any(|(tag, doc)| !tag.trim().is_empty() || !doc.trim().is_empty());
if !has_summary && !has_tags {
return;
}
output.push_str(indent);
output.push_str("/**\n");
if let Some(summary) = summary.map(str::trim).filter(|summary| !summary.is_empty()) {
for line in summary.lines() {
push_wrapped_typescript_doc_line(output, indent, "", "", line.trim());
}
}
if has_summary && has_tags {
output.push_str(indent);
output.push_str(" *\n");
}
for (tag, doc) in tags {
let tag = tag.trim();
let doc = doc.trim();
if tag.is_empty() && doc.is_empty() {
continue;
}
if doc.is_empty() {
push_wrapped_typescript_doc_line(output, indent, "", "", tag);
} else {
push_wrapped_typescript_doc_line(output, indent, &format!("{tag} "), " ", doc);
}
}
output.push_str(indent);
output.push_str(" */\n");
}
fn experimental_doc_tag(experimental: bool) -> Vec<(String, String)> {
if experimental {
vec![(
"@experimental".to_string(),
EXPERIMENTAL_WARNING.to_string(),
)]
} else {
Vec::new()
}
}
fn declaration_doc_tags(experimental: bool, deprecated: bool) -> Vec<(String, String)> {
let mut tags = experimental_doc_tag(experimental);
if deprecated {
tags.push(("@deprecated".to_string(), String::new()));
}
tags
}
fn push_wrapped_typescript_doc_line(
output: &mut String,
indent: &str,
first_prefix: &str,
continuation_prefix: &str,
text: &str,
) {
let max_width = TYPESCRIPT_FORMAT_LINE_LENGTH.saturating_sub(indent.chars().count() + 3);
let text = text.replace("*/", "* /");
if text.trim().is_empty() {
output.push_str(indent);
output.push_str(" *\n");
return;
}
let mut prefix = first_prefix;
let mut current = String::new();
for word in text.split_whitespace() {
let prefix_width = prefix.chars().count();
let current_width = current.chars().count();
let word_width = word.chars().count();
let separator_width = usize::from(!current.is_empty());
if current_width > 0
&& prefix_width + current_width + separator_width + word_width > max_width
{
output.push_str(indent);
output.push_str(" * ");
output.push_str(prefix);
output.push_str(¤t);
output.push('\n');
prefix = continuation_prefix;
current.clear();
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
output.push_str(indent);
output.push_str(" * ");
output.push_str(prefix);
output.push_str(¤t);
output.push('\n');
}
fn typescript_model_field_doc(model: &RenderedModel, field: &RenderedField) -> Option<String> {
if let Some(function) = model
.functions
.iter()
.find(|function| function.args_field_name == field.name)
{
return Some(format!("Arguments for {}.", function.callable_field_name));
}
if let Some(with_arguments) = model
.with_arguments
.iter()
.find(|with_arguments| with_arguments.args_field_name == field.name)
{
return Some(format!(
"Arguments for {}.",
with_arguments.value_field_name
));
}
if let Some(doc) = &field.doc {
return Some(doc.clone());
}
None
}
fn typescript_model_field_doc_by_name(model: &RenderedModel, name: &str) -> Option<String> {
model
.fields
.iter()
.find(|field| field.name == name)
.and_then(|field| typescript_model_field_doc(model, field))
}
fn render_typescript_type_property(
output: &mut String,
indent: &str,
name: &str,
optional: bool,
annotation: &str,
doc: Option<&str>,
) {
render_typescript_doc_comment(output, indent, doc, &[]);
output.push_str(indent);
output.push_str(name);
if optional {
output.push('?');
}
output.push_str(": ");
output.push_str(annotation);
output.push_str(";\n");
}
fn render_flattened_type_properties(
output: &mut String,
indent: &str,
fields: &[RenderedFlattenedField],
) {
for field in fields {
render_typescript_type_property(
output,
indent,
&field.name,
field.optional,
&field.annotation,
field.doc.as_deref(),
);
}
}
fn render_model(
output: &mut String,
model: &RenderedModel,
external_models: &TypeScriptExternalModels,
api_plan: &PlannedSpec,
) {
let doc_tags = experimental_doc_tag(model.experimental);
if !model_uses_invocation_composition(model) {
if model.fields.is_empty() {
render_typescript_doc_comment(output, "", None, &doc_tags);
output.push_str("export type ");
output.push_str(&model.name);
output.push_str(" = Record<string, never>;\n\n");
} else {
render_typescript_doc_comment(output, "", None, &doc_tags);
output.push_str("export interface ");
output.push_str(&model.name);
output.push_str(&render_type_parameter_list(&model.type_parameters));
output.push_str(" {\n");
for field in &model.fields {
if field.flattened_fields.is_empty() {
render_invocation_model_base_property(output, model, field);
} else {
render_flattened_type_properties(output, " ", &field.flattened_fields);
}
}
output.push_str("}\n\n");
}
} else {
render_invocation_model_replace_helper(output, model);
render_typescript_doc_comment(output, "", None, &doc_tags);
output.push_str("export type ");
output.push_str(&model.name);
output.push_str(&render_type_parameter_list(&model.type_parameters));
output.push_str(" = ");
output.push_str(&invocation_model_replace_type_name(&model.name));
output.push('<');
render_invocation_model_base_type(output, model);
output.push_str(", ");
render_function_model_invocations(output, model);
output.push_str(">;\n\n");
}
let planned_record = api_plan
.record(&model.full_name)
.unwrap_or_else(|| panic!("planned model should exist for {}", model.full_name));
external_models.render_model_wire_functions(output, model, planned_record);
}
fn render_service_definition(output: &mut String, service: &RenderedService<'_>) {
let service_doc_tags = declaration_doc_tags(service.experimental, service.deprecated);
render_typescript_doc_comment(output, "", service.doc.as_deref(), &service_doc_tags);
output.push_str("export const ");
output.push_str(&service.attr_name);
output.push_str(" = nexus.service('");
output.push_str(service.wire_name);
output.push_str("', {\n");
for operation in &service.operations {
let operation_doc_tags = declaration_doc_tags(operation.experimental, operation.deprecated);
render_typescript_doc_comment(output, " ", operation.doc.as_deref(), &operation_doc_tags);
output.push_str(" ");
output.push_str(&operation.attr_name);
output.push_str(": nexus.operation<\n");
output.push_str(" ");
output.push_str(
operation
.input
.as_ref()
.map(|input| input.operation_annotation.as_str())
.unwrap_or("void"),
);
output.push_str(",\n");
output.push_str(" ");
output.push_str(&operation.service_output_operation_annotation);
output.push('\n');
output.push_str(" >({ name: ");
output.push_str(&typescript_string_literal(operation.wire_name));
render_operation_type_info(
output,
"inputType",
operation
.input
.as_ref()
.and_then(|input| input.transfer_type_converter.as_deref()),
);
render_operation_type_info(
output,
"outputType",
operation.output_transfer_type_converter.as_deref(),
);
output.push_str(" }),\n");
}
output.push_str("});\n\n");
}
fn render_operation_type_info(output: &mut String, field: &str, converter: Option<&str>) {
let Some(converter) = converter else {
return;
};
output.push_str(", ");
output.push_str(field);
output.push_str(": { transferTypeConverter: ");
output.push_str(converter);
output.push_str(" }");
}
fn resource_client_symbol_name(resource: &PlannedResource) -> String {
format!(
"{}Client",
typescript_ident(&resource.type_name.to_lower_camel_case())
)
}
fn resource_client_bind_function_name(resource: &PlannedResource) -> String {
resource_client_bind_function_name_for_type(&resource.type_name)
}
fn resource_client_bind_function_name_for_type(resource_type_name: &str) -> String {
format!("bind{resource_type_name}Client")
}
fn resource_client_require_function_name(resource: &PlannedResource) -> String {
format!("require{}Client", resource.type_name)
}
fn render_resource(output: &mut String, resource: &PlannedResource, service: &RenderedService<'_>) {
let client_symbol_name = resource_client_symbol_name(resource);
if service.endpoint.is_none() {
output.push_str("const ");
output.push_str(&client_symbol_name);
output.push_str(" = Symbol(");
output.push_str(&typescript_string_literal(&format!(
"nexgen.{}.client",
resource.type_name
)));
output.push_str(");\n\n");
}
output.push_str("export class ");
output.push_str(&resource.type_name);
output.push_str(" {\n");
if service.endpoint.is_none() {
output.push_str(" ");
output.push_str("[");
output.push_str(&client_symbol_name);
output.push_str("]: workflow.NexusServiceClient<typeof ");
output.push_str(&service.attr_name);
output.push_str("> | undefined;\n\n");
}
output.push_str(" public constructor(\n");
for field in &resource.fields {
output.push_str(" public readonly ");
output.push_str(&typescript_generated_field_name(&field.name));
output.push_str(": ");
output.push_str(&typescript_resource_field_annotation(
&field.kind,
field.optional,
field.function.as_ref(),
));
output.push_str(",\n");
}
output.push_str(" ) {}\n");
if resource.methods.is_empty() {
output.push_str("}\n");
return;
}
for method in &resource.methods {
let result_annotation = typescript_resource_method_result_annotation(method);
output.push('\n');
output.push_str(" public async ");
output.push_str(&typescript_generated_field_name(&method.name));
output.push_str("(\n");
for param in &method.params {
output.push_str(" ");
output.push_str(&typescript_generated_field_name(¶m.name));
if param.optional {
output.push('?');
}
output.push_str(": ");
output.push_str(&typescript_resource_field_annotation(
¶m.kind,
false,
param.function.as_ref(),
));
output.push_str(",\n");
}
output.push_str(" ): Promise<");
output.push_str(&result_annotation);
output.push_str("> {\n");
match &method.binding {
PlannedResourceMethodBindingSpec::Operation {
operation_name,
request_plan,
direct_return,
} => {
let operation = service
.operations
.iter()
.find(|operation| operation.name == operation_name)
.expect("bound operation should exist on the resource service");
let branchable_params =
branchable_request_method_params(method, request_plan, operation);
let endpoint_expr = None;
let client_expr = service
.endpoint
.is_none()
.then(|| format!("{}(this)", resource_client_require_function_name(resource)));
if *direct_return {
output.push_str(&render_resource_method_operation_body(
4,
service,
resource,
operation,
endpoint_expr,
client_expr,
&result_annotation,
request_plan,
&branchable_params,
true,
));
} else {
output.push_str(&render_resource_method_operation_body(
4,
service,
resource,
operation,
endpoint_expr,
client_expr,
&result_annotation,
request_plan,
&branchable_params,
false,
));
}
}
PlannedResourceMethodBindingSpec::Stub => {
output.push_str(" throw new Error(");
output.push_str(&typescript_string_literal(&format!(
"{}.{} is not yet implemented",
resource.name,
typescript_generated_field_name(&method.name)
)));
output.push_str(");\n");
}
}
output.push_str(" }\n");
}
output.push_str("}\n");
if service.endpoint.is_none() {
output.push('\n');
output.push_str("export function ");
output.push_str(&resource_client_bind_function_name(resource));
output.push_str("(resource: ");
output.push_str(&resource.type_name);
output.push_str(" | ");
render_resource_structural_type(output, resource);
output.push_str(", client: workflow.NexusServiceClient<typeof ");
output.push_str(&service.attr_name);
output.push_str(">): ");
output.push_str(&resource.type_name);
output.push_str(" {\n");
output.push_str(" const bound = resource instanceof ");
output.push_str(&resource.type_name);
output.push_str(" ? resource : ");
render_resource_constructor(output, resource, "resource", " ");
output.push_str(";\n");
output.push_str(" bound[");
output.push_str(&client_symbol_name);
output.push_str("] = client;\n");
output.push_str(" return bound;\n");
output.push_str("}\n\n");
output.push_str("function ");
output.push_str(&resource_client_require_function_name(resource));
output.push_str("(resource: ");
output.push_str(&resource.type_name);
output.push_str("): workflow.NexusServiceClient<typeof ");
output.push_str(&service.attr_name);
output.push_str("> {\n");
output.push_str(" const client = resource[");
output.push_str(&client_symbol_name);
output.push_str("];\n");
output.push_str(" if (client == null) {\n");
output.push_str(" throw new Error(");
output.push_str(&typescript_string_literal(&format!(
"{} methods require a service endpoint.",
resource.type_name
)));
output.push_str(");\n");
output.push_str(" }\n");
output.push_str(" return client;\n");
output.push_str("}\n");
}
}
fn typescript_resource_method_result_annotation(method: &PlannedResourceMethod) -> String {
let Some(result) = &method.result else {
return "void".to_string();
};
let annotation = match &result.kind {
PlannedResourceMethodResultKind::Resource { type_name } => type_name.clone(),
PlannedResourceMethodResultKind::Value(kind) => {
typescript_resource_field_annotation(kind, result.optional, None)
}
};
if result.optional && !annotation.contains(" | undefined") {
format!("{annotation} | undefined")
} else {
annotation
}
}
fn typescript_resource_field_annotation(
kind: &PlannedType,
optional: bool,
function: Option<&FunctionFieldSpec<PlannedFamily>>,
) -> String {
let base = if let Some(function) = function {
typescript_function_value_annotation(
function
.alternate_type
.as_ref()
.map(typescript_authored_type_annotation)
.as_deref(),
&function_constraint(function),
)
} else {
match kind {
PlannedType::List(value) => {
typescript_array_annotation(&typescript_resource_value_annotation(value))
}
PlannedType::Map(key, value) => format!(
"Record<{}, {}>",
typescript_resource_value_annotation(key),
typescript_resource_value_annotation(value)
),
value => typescript_resource_value_annotation(value),
}
};
if optional {
format!("{base} | undefined")
} else {
base
}
}
fn typescript_resource_value_annotation(value: &PlannedType) -> String {
match value {
PlannedType::Int(IntSpec::I32) => "number".to_string(),
PlannedType::Int(IntSpec::I64) => "Long".to_string(),
PlannedType::Float => "number".to_string(),
PlannedType::Bool => "boolean".to_string(),
PlannedType::String => "string".to_string(),
PlannedType::Bytes => "Uint8Array".to_string(),
PlannedType::TypeParameter(parameter) => parameter.name.clone(),
PlannedType::Enum(enum_type) => enum_type.name.clone(),
PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Enum(enum_type))) => {
enum_type
.replacement
.as_ref()
.and_then(typescript_replacement_type_name)
.unwrap_or_else(|| enum_type.name.clone())
}
PlannedType::Flags(flags_type) => flags_type.name.clone(),
PlannedType::Variant(variant_type) => variant_type.name.clone(),
PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Message(proto))) => proto
.replacement
.as_ref()
.and_then(typescript_replacement_type_name)
.unwrap_or_else(|| proto.model_name.clone()),
PlannedType::External(ExternalTypeSpec::Json(json_type)) => json_type.model_name.clone(),
PlannedType::Record(record) => record.model_name.clone(),
PlannedType::Resource(resource) => resource.type_name.clone(),
PlannedType::Option(inner) => {
format!(
"{} | undefined",
typescript_resource_value_annotation(inner)
)
}
PlannedType::List(inner) => {
typescript_array_annotation(&typescript_resource_value_annotation(inner))
}
PlannedType::Map(key, value) => format!(
"Record<{}, {}>",
typescript_resource_value_annotation(key),
typescript_resource_value_annotation(value)
),
PlannedType::Tuple(items) => format!(
"[{}]",
items
.iter()
.map(typescript_resource_value_annotation)
.collect::<Vec<_>>()
.join(", ")
),
PlannedType::Result { ok, err } => typescript_result_annotation(
ok.as_ref()
.map(|ok| typescript_resource_value_annotation(ok).to_string()),
err.as_ref()
.map(|err| typescript_resource_value_annotation(err).to_string()),
),
PlannedType::External(ExternalTypeSpec::Alias {
type_name,
target: fallback,
..
}) => type_name
.for_language(Language::TypeScript)
.map(str::to_string)
.unwrap_or_else(|| typescript_resource_value_annotation(fallback)),
}
}
fn typescript_resource_field_requirements(kind: &PlannedType) -> TypeScriptRequirements {
let mut requirements = TypeScriptRequirements::default();
match kind {
PlannedType::List(value) => {
collect_typescript_value_requirements(value, &mut requirements);
}
PlannedType::Map(key, value) => {
collect_typescript_value_requirements(key, &mut requirements);
collect_typescript_value_requirements(value, &mut requirements);
}
value => {
collect_typescript_value_requirements(value, &mut requirements);
}
}
requirements
}
fn collect_typescript_value_requirements(
value: &PlannedType,
requirements: &mut TypeScriptRequirements,
) {
match value {
PlannedType::Int(IntSpec::I64) => {
requirements.long = true;
}
PlannedType::Int(IntSpec::I32) => {}
PlannedType::Tuple(items) => {
for item in items {
collect_typescript_value_requirements(item, requirements);
}
}
PlannedType::Result { ok, err } => {
if let Some(ok) = ok {
collect_typescript_value_requirements(ok, requirements);
}
if let Some(err) = err {
collect_typescript_value_requirements(err, requirements);
}
}
PlannedType::External(ExternalTypeSpec::Alias {
type_name,
target: fallback,
..
}) => {
if type_name
.for_language(Language::TypeScript)
.is_some_and(|type_name| type_name.contains("Long"))
{
requirements.long = true;
}
collect_typescript_value_requirements(fallback, requirements);
}
_ => {}
}
}
fn branchable_request_method_params(
method: &PlannedResourceMethod,
request_plan: &RequestPlan,
operation: &RenderedOperation<'_>,
) -> Vec<String> {
let Some(input) = &operation.input else {
return Vec::new();
};
if input.type_parameters.is_empty() {
return Vec::new();
}
let mut used_params = BTreeSet::new();
collect_request_plan_method_params(request_plan, &mut used_params);
method
.params
.iter()
.filter(|param| {
used_params.contains(¶m.name)
&& typescript_resource_field_annotation(
¶m.kind,
param.optional,
param.function.as_ref(),
)
.trim_start()
.starts_with("string | ")
})
.map(|param| param.name.clone())
.collect()
}
fn collect_request_plan_method_params(plan: &RequestPlan, params: &mut BTreeSet<String>) {
match plan {
RequestPlan::Source(RequestPlanSource::MethodParam(name)) => {
params.insert(name.clone());
}
RequestPlan::Source(RequestPlanSource::ResourceField(_)) => {}
RequestPlan::Construct { fields, .. } => {
for field in fields {
collect_request_plan_method_params(&field.value, params);
}
}
}
}
fn render_resource_method_operation_body(
indent: usize,
service: &RenderedService<'_>,
resource: &PlannedResource,
operation: &RenderedOperation<'_>,
endpoint_expr: Option<String>,
client_expr: Option<String>,
result_annotation: &str,
request_plan: &RequestPlan,
branchable_params: &[String],
direct_return: bool,
) -> String {
let indent_str = " ".repeat(indent);
if let Some((param_name, remaining)) = branchable_params.split_first() {
let param_ident = typescript_generated_field_name(param_name);
let mut output = String::new();
output.push_str(&indent_str);
output.push_str("if (typeof ");
output.push_str(¶m_ident);
output.push_str(" === \"string\") {\n");
output.push_str(&render_resource_method_operation_body(
indent + 2,
service,
resource,
operation,
endpoint_expr.clone(),
client_expr.clone(),
result_annotation,
request_plan,
remaining,
direct_return,
));
output.push_str(&indent_str);
output.push_str("} else {\n");
output.push_str(&render_resource_method_operation_body(
indent + 2,
service,
resource,
operation,
endpoint_expr,
client_expr,
result_annotation,
request_plan,
remaining,
direct_return,
));
output.push_str(&indent_str);
output.push_str("}\n");
return output;
}
let request_expr = render_request_plan_typescript(request_plan);
let mut output = String::new();
output.push_str(&indent_str);
output.push_str("const request = ");
output.push_str(&request_expr);
output.push_str(";\n");
let Some(client_expr) = client_expr else {
if direct_return {
output.push_str(&indent_str);
if result_annotation == "void" {
output.push_str("await ");
} else {
output.push_str("return await ");
}
output.push_str(&typescript_operation_function_name(service, operation));
output.push('(');
if let Some(endpoint_expr) = &endpoint_expr {
output.push_str(endpoint_expr);
output.push_str(", ");
}
output.push_str("request");
output.push_str(");\n");
} else {
output.push_str(&indent_str);
output.push_str("const handle = await ");
output.push_str(&typescript_operation_function_name(service, operation));
output.push('(');
if let Some(endpoint_expr) = &endpoint_expr {
output.push_str(endpoint_expr);
output.push_str(", ");
}
output.push_str("request");
output.push_str(");\n");
output.push_str(&indent_str);
if result_annotation == "void" {
output.push_str("await handle.result();\n");
} else {
output.push_str("return await handle.result();\n");
}
}
return output;
};
let returns_direct = operation.output_transform_expr.is_some()
|| operation.output_resource_return.is_some()
|| operation.output_direct_result;
if !returns_direct && direct_return {
output.push_str(&indent_str);
if result_annotation == "void" {
output.push_str("await ");
} else {
output.push_str("return await ");
}
render_resource_start_operation(&mut output, service, operation, &client_expr, "request");
return output;
}
if returns_direct {
output.push_str(&indent_str);
output.push_str("const requestProto = ");
output.push_str(&typescript_operation_input_expr(operation));
output.push_str(";\n");
output.push_str(&indent_str);
output.push_str("const handle = await ");
render_resource_start_operation(
&mut output,
service,
operation,
&client_expr,
"requestProto",
);
if let Some(transform_expr) = &operation.output_transform_expr {
output.push_str(&indent_str);
output.push_str("const result = await handle.result();\n");
output.push_str(&indent_str);
output.push_str("return ");
output.push_str(transform_expr);
output.push_str(";\n");
} else if let Some(resource_return) = &operation.output_resource_return {
output.push_str(&indent_str);
output.push_str("const result = await handle.result();\n");
output.push_str(&indent_str);
output.push_str("return ");
output.push_str(&resource_client_bind_function_name_for_type(
&resource_return.resource_type_name,
));
output.push_str("(\n");
output.push_str(&indent_str);
output.push_str(" new ");
output.push_str(&resource_return.resource_type_name);
output.push_str("(\n");
for binding in &resource_return.bindings {
output.push_str(&indent_str);
output.push_str(" ");
output.push_str(&resource_return_binding_expr_typescript(binding));
output.push_str(",\n");
}
output.push_str(&indent_str);
output.push_str(" ),\n");
output.push_str(&indent_str);
output.push_str(" ");
output.push_str(&client_expr);
output.push_str(",\n");
output.push_str(&indent_str);
output.push_str(");\n");
} else if operation.output_direct_result {
if let Some(returned_resource) = service
.resources
.iter()
.find(|candidate| candidate.type_name == operation.output_annotation)
{
output.push_str(&indent_str);
output.push_str("const resource = await handle.result();\n");
output.push_str(&indent_str);
output.push_str("return ");
output.push_str(&resource_client_bind_function_name(returned_resource));
output.push_str("(resource, ");
output.push_str(&client_expr);
output.push_str(");\n");
} else {
output.push_str(&indent_str);
output.push_str("return await handle.result();\n");
}
}
} else {
output.push_str(&indent_str);
output.push_str("const handle = await ");
render_resource_start_operation(&mut output, service, operation, &client_expr, "request");
output.push_str(&indent_str);
if result_annotation == "void" {
output.push_str("await handle.result();\n");
} else {
output.push_str("return await handle.result();\n");
}
}
output
}
fn render_resource_start_operation(
output: &mut String,
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
client_expr: &str,
input_expr: &str,
) {
output.push_str(client_expr);
output.push_str(".startOperation(\n");
output.push_str(" ");
output.push_str(&service.attr_name);
output.push_str(".operations.");
output.push_str(&operation.attr_name);
output.push_str(",\n");
output.push_str(" ");
output.push_str(input_expr);
output.push_str(",\n");
output.push_str(" );\n");
}
fn render_request_plan_typescript(plan: &RequestPlan) -> String {
render_request_plan(
plan,
typescript_generated_field_name,
|name, value| format!("{name}: {value}"),
|_message_name, fields| {
if fields.is_empty() {
"{}".to_string()
} else {
format!("{{ {} }}", fields.join(", "))
}
},
|name| format!("this.{}", typescript_generated_field_name(name)),
typescript_generated_field_name,
)
}
fn resource_return_binding_expr_typescript(
binding: &PlannedOperationResourceFieldBinding,
) -> String {
match &binding.source {
ResolvedResourceBindingSource::RequestField {
field_name,
proto_field_name,
hidden,
} => {
if *hidden {
let expr = format!(
"requestProto.{}",
typescript_generated_field_name(proto_field_name)
);
if binding.optional {
format!("{expr} ?? undefined")
} else {
format!(
"requiredField({expr}, {}, {})",
typescript_string_literal("resource"),
typescript_string_literal(&typescript_generated_field_name(
&binding.field_name
)),
)
}
} else {
format!("request.{}", typescript_generated_field_name(field_name))
}
}
ResolvedResourceBindingSource::ResultField {
proto_field_name, ..
} => {
let expr = format!(
"result.{}",
typescript_generated_field_name(proto_field_name)
);
if binding.optional {
format!("{expr} ?? undefined")
} else {
format!(
"requiredField({expr}, {}, {})",
typescript_string_literal("resource"),
typescript_string_literal(&typescript_generated_field_name(
&binding.field_name
)),
)
}
}
}
}
fn render_operation_function(
output: &mut String,
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
) {
let returns_direct = operation.output_transform_expr.is_some()
|| operation.output_resource_return.is_some()
|| operation.output_direct_result;
let mut doc_tags = Vec::new();
if service.endpoint.is_none() {
doc_tags.push((
"@param endpoint -".to_string(),
"Endpoint for the service.".to_string(),
));
}
if operation.input.is_some() {
doc_tags.push((
"@param request -".to_string(),
"Request for the operation.".to_string(),
));
}
if let Some(return_doc) = &operation.return_doc {
doc_tags.push(("@returns".to_string(), return_doc.clone()));
}
if operation.experimental {
doc_tags.push((
"@experimental".to_string(),
EXPERIMENTAL_WARNING.to_string(),
));
}
if operation.deprecated {
doc_tags.push(("@deprecated".to_string(), String::new()));
}
render_typescript_doc_comment(output, "", operation.doc.as_deref(), &doc_tags);
let input_type_parameters = operation
.input
.as_ref()
.map(|input| input.type_parameters.as_slice())
.unwrap_or(&[]);
if input_type_parameters.is_empty() {
output.push_str("export async function ");
output.push_str(&typescript_operation_function_name(service, operation));
output.push_str("(\n");
} else {
render_named_generic_function_start(
output,
&format!(
"export async function {}",
typescript_operation_function_name(service, operation)
),
input_type_parameters,
0,
);
}
if service.endpoint.is_none() {
output.push_str(" endpoint: string,\n");
}
if let Some(input) = &operation.input {
output.push_str(" request: ");
if input.api_omitted_fields.is_empty() {
output.push_str(&input.annotation);
} else {
output.push_str(&operation_input_alias_reference(service, operation, input));
}
output.push_str(",\n");
}
if returns_direct {
output.push_str("): Promise<");
output.push_str(&operation.output_annotation);
output.push_str("> {\n");
} else {
output.push_str("): Promise<workflow.NexusOperationHandle<");
output.push_str(&operation.output_operation_annotation);
output.push_str(">> {\n");
output.push_str(" const client = workflow.createNexusServiceClient({\n");
output.push_str(" service: ");
output.push_str(&service.attr_name);
output.push_str(",\n");
output.push_str(" endpoint: ");
output.push_str(&typescript_service_endpoint_expr(service));
output.push_str(",\n");
output.push_str(" });\n");
output.push_str(" return await client.startOperation(\n");
output.push_str(" ");
output.push_str(&service.attr_name);
output.push_str(".operations.");
output.push_str(&operation.attr_name);
output.push_str(",\n");
output.push_str(" ");
output.push_str(&typescript_operation_input_expr(operation));
output.push_str(",\n");
output.push_str(" );\n");
output.push_str("}\n");
return;
}
output.push_str(" const client = workflow.createNexusServiceClient({\n");
output.push_str(" service: ");
output.push_str(&service.attr_name);
output.push_str(",\n");
output.push_str(" endpoint: ");
output.push_str(&typescript_service_endpoint_expr(service));
output.push_str(",\n");
output.push_str(" });\n");
output.push_str(" const requestProto = ");
output.push_str(&typescript_operation_input_expr(operation));
output.push_str(";\n");
output.push_str(" const handle = await client.startOperation(\n");
output.push_str(" ");
output.push_str(&service.attr_name);
output.push_str(".operations.");
output.push_str(&operation.attr_name);
output.push_str(",\n");
output.push_str(" requestProto,\n");
output.push_str(" );\n");
if let Some(transform_expr) = &operation.output_transform_expr {
output.push_str(" const result = await handle.result();\n");
output.push_str(" return ");
output.push_str(transform_expr);
output.push_str(";\n");
} else if let Some(resource_return) = &operation.output_resource_return {
output.push_str(" const result = await handle.result();\n");
if service.endpoint.is_none() {
output.push_str(" return ");
output.push_str(&resource_client_bind_function_name_for_type(
&resource_return.resource_type_name,
));
output.push('(');
} else {
output.push_str(" return ");
}
output.push_str("new ");
output.push_str(&resource_return.resource_type_name);
output.push_str("(\n");
for binding in &resource_return.bindings {
output.push_str(" ");
output.push_str(&resource_return_binding_expr_typescript(binding));
output.push_str(",\n");
}
if service.endpoint.is_none() {
output.push_str(" ), endpoint);\n");
} else {
output.push_str(" );\n");
}
} else if operation.output_direct_result {
if let Some(resource) = service
.resources
.iter()
.find(|resource| resource.type_name == operation.output_annotation)
{
output.push_str(" const resource = await handle.result();\n");
output.push_str(" return ");
if service.endpoint.is_none() {
output.push_str(&resource_client_bind_function_name(resource));
output.push_str("(resource, endpoint);\n");
} else {
render_resource_constructor(output, resource, "resource", " ");
output.push_str(";\n");
}
} else {
output.push_str(" return await handle.result();\n");
}
}
output.push_str("}\n");
}
fn operation_input_alias_name(
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
) -> String {
format!(
"{}Input",
typescript_operation_function_name(service, operation).to_upper_camel_case()
)
}
fn operation_input_alias_reference(
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
input: &RenderedOperationInput,
) -> String {
let parameters = input
.type_parameters
.iter()
.map(|parameter| parameter.name.as_str())
.collect::<Vec<_>>();
format!(
"{}{}",
operation_input_alias_name(service, operation),
if parameters.is_empty() {
String::new()
} else {
format!("<{}>", parameters.join(", "))
}
)
}
fn render_operation_input_alias(
output: &mut String,
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
) {
let Some(input) = &operation.input else {
return;
};
if input.api_omitted_fields.is_empty() {
return;
}
output.push_str("type ");
output.push_str(&operation_input_alias_name(service, operation));
output.push_str(&render_type_parameter_list(&input.type_parameters));
output.push_str(" = ");
output.push_str(&input.annotation);
output.push_str(" & { ");
for (index, field) in input.api_omitted_fields.iter().enumerate() {
if index > 0 {
output.push_str("; ");
}
output.push_str(field);
output.push_str("?: never");
}
output.push_str(" };\n\n");
}
fn render_resource_constructor(
output: &mut String,
resource: &PlannedResource,
value_expr: &str,
indent: &str,
) {
output.push_str("new ");
output.push_str(&resource.type_name);
output.push_str("(\n");
for field in &resource.fields {
output.push_str(indent);
output.push_str(" ");
output.push_str(value_expr);
output.push('.');
output.push_str(&typescript_generated_field_name(&field.name));
output.push_str(",\n");
}
output.push_str(indent);
output.push(')');
}
fn render_resource_structural_type(output: &mut String, resource: &PlannedResource) {
output.push_str("{ ");
for field in &resource.fields {
output.push_str("readonly ");
output.push_str(&typescript_generated_field_name(&field.name));
output.push_str(": ");
output.push_str(&typescript_resource_field_annotation(
&field.kind,
field.optional,
field.function.as_ref(),
));
output.push_str("; ");
}
output.push('}');
}
fn typescript_operation_function_name(
service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
) -> String {
if service.endpoint.is_none() {
format!("{}Request", operation.attr_name)
} else {
operation.attr_name.clone()
}
}
fn typescript_operation_public_return_annotation(
_service: &RenderedService<'_>,
operation: &RenderedOperation<'_>,
) -> String {
if operation.output_transform_expr.is_some()
|| operation.output_resource_return.is_some()
|| operation.output_direct_result
{
operation.output_annotation.clone()
} else {
format!(
"workflow.NexusOperationHandle<{}>",
operation.output_operation_annotation
)
}
}
fn typescript_service_endpoint_expr(service: &RenderedService<'_>) -> String {
service
.endpoint
.as_ref()
.map(|endpoint| typescript_string_literal(endpoint))
.unwrap_or_else(|| "endpoint".to_string())
}
fn typescript_operation_input_expr(operation: &RenderedOperation<'_>) -> String {
let Some(input) = &operation.input else {
return "undefined".to_string();
};
input.to_wire_expr.clone()
}
pub(in crate::generator) fn required_field_expr(
value_expr: &str,
owner_name: &str,
field_name: &str,
) -> String {
format!(
"requiredField({value_expr}, {}, {})",
typescript_string_literal(owner_name),
typescript_string_literal(field_name)
)
}
pub(in crate::generator) fn typescript_ident(name: &str) -> String {
if is_typescript_keyword(name) {
format!("{name}_")
} else {
name.to_string()
}
}
pub(in crate::generator) fn typescript_generated_field_name(name: &str) -> String {
typescript_ident(&name.to_lower_camel_case())
}
fn typescript_string_literal(value: &str) -> String {
format!("{value:?}")
}
fn is_typescript_keyword(name: &str) -> bool {
matches!(
name,
"break"
| "case"
| "catch"
| "class"
| "const"
| "continue"
| "debugger"
| "default"
| "delete"
| "do"
| "else"
| "enum"
| "export"
| "extends"
| "false"
| "finally"
| "for"
| "function"
| "if"
| "import"
| "in"
| "instanceof"
| "new"
| "null"
| "return"
| "super"
| "switch"
| "this"
| "throw"
| "true"
| "try"
| "typeof"
| "var"
| "void"
| "while"
| "with"
| "yield"
| "as"
| "implements"
| "interface"
| "let"
| "package"
| "private"
| "protected"
| "public"
| "static"
)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::SupportFiles;
use crate::descriptors::DescriptorIndex;
use crate::generator::{
GenerateFilesOptions, GeneratedOutputLayout, GenerationMode,
generate_files_for_tree_with_mode_and_options, generate_source,
};
use crate::language::Language;
use crate::spec::ApiSpecTree;
use crate::spec::SupportFragmentSpec;
fn sample_input_path(root: &std::path::Path) -> PathBuf {
root.join("advanced/samples/inputs/workflow-service.wit")
}
fn type_roundtrip_input_path(root: &std::path::Path) -> PathBuf {
root.join("advanced/samples/inputs/type-roundtrip.wit")
}
fn linked_inputs_path(root: &std::path::Path) -> PathBuf {
root.join("advanced/samples/inputs/deps")
}
fn example_input_paths(root: &std::path::Path, input_path: PathBuf) -> Vec<PathBuf> {
vec![input_path, linked_inputs_path(root)]
}
fn sample_typescript_output_path(root: &std::path::Path) -> PathBuf {
root.join("advanced/samples/typescript/wit/workflow-service")
}
fn sample_support_files(root: &std::path::Path) -> SupportFiles {
let path = root.join(
"advanced/samples/inputs/deps/nexus-temporal-types/typescript/temporal_model_converters.ts",
);
SupportFiles {
fragments: vec![SupportFragmentSpec {
path: path.to_string_lossy().into_owned(),
contents: fs::read_to_string(path).unwrap(),
namespace: None,
}],
}
}
fn ensure_typescript_dependencies(root: &std::path::Path) {
let example_dir = root.join("advanced/samples/typescript");
if example_dir.join("node_modules").exists() {
return;
}
let status = Command::new("npm")
.current_dir(&example_dir)
.args(["install", "--no-fund", "--no-audit"])
.status()
.unwrap();
assert!(status.success());
}
fn read_typescript_output_files(dir: &Path) -> BTreeMap<PathBuf, String> {
fn visit(root: &Path, dir: &Path, files: &mut BTreeMap<PathBuf, String>) {
let mut entries = fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.collect::<Vec<_>>();
entries.sort();
for path in entries {
if path.is_dir() {
visit(root, &path, files);
} else if path.extension().and_then(|extension| extension.to_str()) == Some("ts") {
files.insert(
path.strip_prefix(root).unwrap().to_path_buf(),
fs::read_to_string(&path).unwrap(),
);
}
}
}
let mut files = BTreeMap::new();
visit(dir, dir, &mut files);
files
}
#[test]
fn renders_sample_output() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let spec = crate::parser::load_api_spec_from_wit_for_language_with_inputs(
Language::TypeScript,
&example_input_paths(&root, sample_input_path(&root)),
)
.unwrap();
let descriptors =
DescriptorIndex::load(&root.join("advanced/samples/descriptors/temporal_api.bin"))
.unwrap();
let support = sample_support_files(&root);
let generated = generate_files_for_tree_with_mode_and_options(
Language::TypeScript,
ApiSpecTree::single(spec.clone()),
&descriptors,
&support,
GenerationMode::NativeApi,
GenerateFilesOptions::default(),
)
.unwrap();
assert_eq!(generated.layout, GeneratedOutputLayout::Directory);
ensure_typescript_dependencies(&root);
let temp_dir = std::env::temp_dir().join(format!(
"nexgen-typescript-snapshot-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&temp_dir).unwrap();
for (relative_path, contents) in &generated.files {
let path = temp_dir.join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
let status = Command::new("npm")
.current_dir(root.join("advanced/samples/typescript"))
.args([
"exec",
"--",
"prettier",
"--write",
"--print-width",
"88",
temp_dir.to_str().unwrap(),
])
.status()
.unwrap();
assert!(status.success());
let output = read_typescript_output_files(&temp_dir);
let expected = read_typescript_output_files(&sample_typescript_output_path(&root));
assert_eq!(output, expected);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn renders_required_fields_and_custom_message_types() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let spec = crate::parser::load_api_spec_from_wit_for_language_with_inputs(
Language::TypeScript,
&example_input_paths(&root, sample_input_path(&root)),
)
.unwrap();
let descriptors =
DescriptorIndex::load(&root.join("advanced/samples/descriptors/temporal_api.bin"))
.unwrap();
let support = sample_support_files(&root);
let output =
generate_source(Language::TypeScript, spec.clone(), &descriptors, &support).unwrap();
assert!(output.contains("### support.ts"));
assert!(output.contains("### index.ts"));
let index_output = output
.split("### index.ts")
.nth(1)
.expect("rendered output should include index.ts");
assert!(!index_output.contains("export * from './support.ts';"));
assert!(output.contains("export function retryPolicyFromProto("));
assert!(!index_output.contains("export const SignalWithStartWorkflowRequest = {"));
assert!(!index_output.contains("const UserMetadata = {"));
assert!(index_output.contains("function userMetadataFromProto("));
assert!(index_output.contains("function signalWithStartWorkflowRequestToProto<"));
assert!(!output.contains("type _RequestWithFunctionField<"));
assert!(!output.contains("type _RequestWithArgumentsField<"));
assert!(!output.contains("type SignalWithStartWorkflowRequestBase = {"));
assert!(output.contains("export type SignalWithStartWorkflowRequest<"));
assert!(output.contains("export type ReplaceSignalWithStartWorkflowRequest<Base, New>"));
assert!(output.contains(
"WorkflowFn extends (...args: any[]) => Promise<any> = (...args: any[]) => Promise<any>,"
));
assert!(output.contains(
"SignalValue extends workflow.SignalDefinition<any[]> = workflow.SignalDefinition<any[]>"
));
assert!(output.contains("> = ReplaceSignalWithStartWorkflowRequest<"));
assert!(output.contains(
"SignalValue extends workflow.SignalDefinition<infer Args, any> ? Args : never"
));
assert!(output.contains("SignalArgs extends any[] = SignalValue extends"));
assert!(output.contains("signalArgs: SignalArgs | Readonly<SignalArgs>;"));
assert!(output.contains("signalArgs?: SignalArgs | Readonly<SignalArgs>;"));
assert!(output.contains(
"Workflow type name or workflow function identifying the workflow to start."
));
assert!(output.contains("workflow: string;"));
assert!(output.contains("Arguments for workflow."));
assert!(output.contains("args?: ReadonlyArray<unknown>;"));
assert!(output.contains("Arguments for signal."));
assert!(output.contains("signalArgs?: ReadonlyArray<unknown>;"));
assert!(output.contains("id: string;"));
assert!(output.contains("taskQueue: string;"));
assert!(output.contains("runTimeout?: common.Duration;"));
assert!(output.contains("idReusePolicy?: common.WorkflowIdReusePolicy;"));
assert!(output.contains("common.WorkflowIdReusePolicy.ALLOW_DUPLICATE"));
assert!(output.contains("idConflictPolicy?: common.WorkflowIdConflictPolicy;"));
assert!(output.contains("workflowIdConflictPolicy:"));
assert!(output.contains("model.idConflictPolicy == null"));
assert!(output.contains("workflowIdConflictPolicyToProto(model.idConflictPolicy)"));
assert!(!output.contains("identity?: string;"));
assert!(output.contains("memo?: Record<string, unknown>;"));
assert!(output.contains("searchAttributes?: common.TypedSearchAttributes;"));
assert!(!output.contains("common.TypedSearchAttributes | common.SearchAttributes"));
assert!(output.contains("versioningOverride?: common.VersioningOverride;"));
assert!(output.contains("priority?: common.Priority;"));
assert!(output.contains("signal: string;"));
assert!(output.contains("staticSummary?: string;"));
assert!(output.contains("staticDetails?: string;"));
assert!(!output.contains("userMetadata?: UserMetadata;"));
assert!(!output.contains("namespace?: string;"));
assert!(output.contains("namespace: workflowNamespace(),"));
assert!(output.contains("workflowType: workflowTypeToProto("));
assert!(output.contains("workflowFunctionName("));
assert!(output.contains("input: requestArgsToPayloads(model.args),"));
assert!(output.contains("signalInput: requestArgsToPayloads(model.signalArgs),"));
assert!(!output.contains("_RequestArgsToPayloads"));
assert!(output.contains("signalName: signalFunctionName("));
assert!(!output.contains("signalName: ((value) =>"));
assert!(output.contains("workflowType: workflowTypeToProto("));
assert!(output.contains("taskQueue: taskQueueToProto("));
assert!(output.contains(
"workflowRunTimeout: model.runTimeout == null ? undefined : durationToProto(model.runTimeout),"
));
assert!(output.contains("memo: model.memo == null ? undefined : memoToProto(model.memo),"));
assert!(output.contains(
"searchAttributes: model.searchAttributes == null ? undefined : searchAttributesToProto(model.searchAttributes),"
));
assert!(output.contains(
"priority: model.priority == null ? undefined : priorityToProto(model.priority),"
));
assert!(output.contains("model.staticSummary == null && model.staticDetails == null"));
assert!(output.contains("summary: model.staticSummary == null"));
assert!(output.contains("configuredPayloadConverter().toPayload(model.staticSummary)"));
assert!(output.contains("common.toPayloads(configuredPayloadConverter(), ...args)"));
assert!(!output.contains("common.defaultPayloadConverter"));
assert!(!output.contains("payloadToProto(payload: unknown"));
assert!(!output.contains("function isPayload("));
assert!(output.contains(
"versioningOverride: model.versioningOverride == null ? undefined : versioningOverrideToProto(model.versioningOverride),"
));
assert!(output.contains("export function taskQueueFromProto("));
assert!(output.contains("export function taskQueueToProto("));
assert!(output.contains(
"): temporal.api.workflowservice.v1.ISignalWithStartWorkflowExecutionRequest | undefined {"
));
assert!(output.contains("headers?: common.Headers;"));
assert!(!output.contains("export interface Header"));
assert!(!output.contains("export interface WorkflowType"));
assert!(!output.contains("export interface TaskQueue"));
assert!(!output.contains("export interface Duration"));
assert!(!output.contains("export interface Memo"));
assert!(!output.contains("export interface SearchAttributes"));
assert!(!output.contains("export interface Priority"));
assert!(!output.contains("export interface VersioningOverride"));
assert!(!output.contains("SignalWithStartWorkflowRequest = {\n fromProto("));
assert!(!output.contains("SignalWithStartWorkflowRequest.fromProto"));
assert!(!output.contains("export interface SignalWithStartWorkflowRequest {"));
assert!(!output.contains("export interface RetryPolicy"));
assert!(!output.contains("export enum WorkflowIdReusePolicy"));
assert!(!output.contains("export enum WorkflowIdConflictPolicy"));
assert!(!output.contains("signalWithStartWorkflowExecution("));
assert!(output.contains("export async function signalWithStartWorkflow<"));
assert!(output.contains("type SignalWithStartWorkflowInput<WorkflowFn extends"));
assert!(output.contains("headers?: never"));
assert!(output.contains("request: SignalWithStartWorkflowInput<WorkflowFn, SignalValue>,"));
assert!(output.contains("const client = workflow.createNexusServiceClient({"));
assert!(!output.contains("export class WorkflowServiceClient"));
assert!(!output.contains("from './temporal_model_converters.ts'"));
assert!(!output.contains("// Included from support.$typescript"));
let type_roundtrip_spec = crate::parser::load_api_spec_from_wit_for_language_with_inputs(
Language::TypeScript,
&example_input_paths(&root, type_roundtrip_input_path(&root)),
)
.unwrap();
let type_roundtrip_output = generate_source(
Language::TypeScript,
type_roundtrip_spec,
&descriptors,
&support,
)
.unwrap();
assert!(type_roundtrip_output.contains("retryPolicy: common.RetryPolicy;"));
assert!(type_roundtrip_output.contains("requiredField(model.retryPolicy"));
assert!(!type_roundtrip_output.contains("retryPolicyOperation"));
}
#[test]
fn renders_typescript_enum_overrides() {
let wit = r#"
package temporal:nexus@1.0.0;
world system {
export workflow-service;
}
/// @nexus.endpoint "__temporal_system"
interface workflow-service {
use nexus:temporal-types/model@1.0.0.{duration, placeholder, workflow-id-reuse-policy};
/// @nexus.proto "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest"
record signal-with-start-workflow-request {
workflow-id: string,
workflow-id-reuse-policy: option<workflow-id-reuse-policy>,
/// @nexus.omit
namespace: placeholder,
/// @nexus.omit
workflow-type: placeholder,
/// @nexus.omit
task-queue: placeholder,
/// @nexus.omit
input: placeholder,
/// @nexus.omit
workflow-execution-timeout: placeholder,
/// @nexus.omit
workflow-run-timeout: placeholder,
/// @nexus.omit
workflow-task-timeout: placeholder,
/// @nexus.omit
identity: placeholder,
/// @nexus.omit
request-id: placeholder,
/// @nexus.omit
workflow-id-conflict-policy: placeholder,
/// @nexus.omit
signal-name: placeholder,
/// @nexus.omit
signal-input: placeholder,
/// @nexus.omit
control: placeholder,
/// @nexus.omit
retry-policy: placeholder,
/// @nexus.omit
cron-schedule: placeholder,
/// @nexus.omit
memo: placeholder,
/// @nexus.omit
search-attributes: placeholder,
/// @nexus.omit
header: placeholder,
workflow-start-delay: option<duration>,
/// @nexus.omit
user-metadata: placeholder,
/// @nexus.omit
links: placeholder,
/// @nexus.omit
versioning-override: placeholder,
/// @nexus.omit
priority: placeholder,
/// @nexus.omit
time-skipping-config: placeholder,
}
/// @nexus.proto "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse"
record signal-with-start-workflow-response {
run-id: option<string>,
started: option<bool>,
/// @nexus.omit
signal-link: placeholder,
}
signal-with-start-workflow-execution: func(
request: signal-with-start-workflow-request,
) -> signal-with-start-workflow-response;
}
"#;
let spec = crate::parser::parse_api_spec_from_wit_for_language_with_inputs(
Language::TypeScript,
wit,
PathBuf::from("inline.wit"),
&[linked_inputs_path(&PathBuf::from(env!(
"CARGO_MANIFEST_DIR"
)))],
)
.unwrap();
let descriptors = DescriptorIndex::load(
&PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("advanced/samples/descriptors/temporal_api.bin"),
)
.unwrap();
let output = generate_source(
Language::TypeScript,
spec.clone(),
&descriptors,
&SupportFiles::default(),
)
.unwrap();
assert!(output.contains("workflowIdReusePolicy?: common.WorkflowIdReusePolicy;"));
assert!(output.contains(
"workflowIdReusePolicy: model.workflowIdReusePolicy == null ? undefined : workflowIdReusePolicyToProto(model.workflowIdReusePolicy)"
));
assert!(!output.contains("export enum WorkflowIdReusePolicy"));
}
#[test]
fn renders_experimental_annotations() {
let wit = r#"
package temporal:nexus@1.0.0;
world system {
export example-service;
}
/// @nexus.endpoint "example"
/// @nexus.experimental
interface example-service {
/// @nexus.experimental
record request {
id: string,
}
/// @nexus.experimental
record response {
ok: bool,
}
/// @nexus.experimental
/// @nexus.doc "Runs example."
request-op: func(request: request) -> response;
}
"#;
let spec = crate::parser::parse_api_spec_from_wit_for_language(
Language::TypeScript,
wit,
PathBuf::from("inline.wit"),
)
.unwrap();
let descriptors = DescriptorIndex::load(
&PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("advanced/samples/descriptors/temporal_api.bin"),
)
.unwrap();
let output = generate_source(
Language::TypeScript,
spec.clone(),
&descriptors,
&SupportFiles::default(),
)
.unwrap();
assert!(output.contains(
"/**\n * @experimental This API is experimental and subject to change.\n */\nexport interface Request"
));
assert!(output.contains(
"/**\n * @experimental This API is experimental and subject to change.\n */\nexport const exampleService"
));
assert!(output.contains(
" /**\n * Runs example.\n *\n * @experimental This API is experimental and subject to change.\n */\n requestOp:"
));
assert!(output.contains(
" * Runs example.\n *\n * @param request - Request for the operation.\n * @experimental This API is experimental and subject to change."
));
}
#[test]
fn renders_endpoint_parameter_when_service_endpoint_is_missing() {
let wit = r#"
package temporal:nexus@1.0.0;
world system {
export example-service;
}
interface example-service {
use nexus:temporal-types/model@1.0.0.{retry-policy};
example-operation: func(request: retry-policy) -> retry-policy;
ping: func(request: retry-policy);
}
"#;
let spec = crate::parser::parse_api_spec_from_wit_for_language_with_inputs(
Language::TypeScript,
wit,
PathBuf::from("inline.wit"),
&[linked_inputs_path(&PathBuf::from(env!(
"CARGO_MANIFEST_DIR"
)))],
)
.unwrap();
let descriptors = DescriptorIndex::load(
&PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("advanced/samples/descriptors/temporal_api.bin"),
)
.unwrap();
let output = generate_source(
Language::TypeScript,
spec.clone(),
&descriptors,
&SupportFiles::default(),
)
.unwrap();
assert!(output.contains("public constructor(endpoint: string)"));
assert!(!output.contains("private readonly endpoint"));
assert!(output.contains(
"private readonly client: workflow.NexusServiceClient<typeof exampleService>;"
));
assert!(output.contains("return await this.client.startOperation("));
assert!(output.contains("exampleService.operations.exampleOperation"));
assert!(output.contains("exampleService.operations.ping"));
assert!(output.contains(" endpoint,\n"));
assert!(!output.contains("request: void"));
}
}