use std::collections::BTreeSet;
use convert_case::{Case, Casing};
use itertools::Itertools;
use super::caps::{is_napi_supported, is_required_message_body, message_type_name};
use crate::analysis::{EmitShape, RequestParam, RequestType};
use crate::codegen::{BindingMode, BindingsConfig, MethodHandler, ServiceHandler};
use crate::parsing::types::unified_to_typescript;
use crate::utils::last_segment;
const RESTRICTED_GLOBAL_NAMES: &[&str] = &[
"Array",
"ArrayBuffer",
"Atomics",
"BigInt",
"BigInt64Array",
"BigUint64Array",
"Boolean",
"DataView",
"Date",
"Error",
"EvalError",
"Float32Array",
"Float64Array",
"Function",
"Infinity",
"Int16Array",
"Int32Array",
"Int8Array",
"Intl",
"JSON",
"Map",
"Math",
"NaN",
"Number",
"Object",
"Promise",
"Proxy",
"RangeError",
"ReferenceError",
"Reflect",
"RegExp",
"Set",
"SharedArrayBuffer",
"String",
"Symbol",
"SyntaxError",
"TypeError",
"URIError",
"Uint16Array",
"Uint32Array",
"Uint8Array",
"Uint8ClampedArray",
"WeakMap",
"WeakSet",
"undefined",
];
fn ts_type_ident(name: &str) -> String {
if RESTRICTED_GLOBAL_NAMES.contains(&name) {
format!("{name}$")
} else {
name.to_string()
}
}
fn ts_type_import_clause(name: &str) -> String {
if RESTRICTED_GLOBAL_NAMES.contains(&name) {
format!("type {name} as {name}$")
} else {
format!("type {name}")
}
}
fn format_jsdoc(documentation: Option<&str>, indent: &str) -> String {
let Some(doc) = documentation else {
return String::new();
};
let trimmed = doc.trim();
if trimmed.is_empty() {
return String::new();
}
let lines: Vec<String> = trimmed
.lines()
.map(|l| format!("{} * {}", indent, l.trim()))
.collect();
format!("{}/**\n{}\n{} */\n", indent, lines.join("\n"), indent)
}
fn generate_error_classes(bindings: &BindingsConfig) -> String {
let base = &bindings.ts_error_base_class;
let prefix = &bindings.ts_error_code_prefix;
format!(
r#"// ── {base} error hierarchy ────────────────────────────────────────────────────────
/** Base class for all {base} errors. */
export class {base} extends Error {{
readonly errorCode: string;
constructor(message: string, errorCode: string) {{
super(message);
this.name = "{base}";
this.errorCode = errorCode;
}}
}}
export class NotFoundError extends {base} {{
constructor(message: string) {{
super(message, "RESOURCE_NOT_FOUND");
this.name = "NotFoundError";
}}
}}
export class AlreadyExistsError extends {base} {{
constructor(message: string) {{
super(message, "RESOURCE_ALREADY_EXISTS");
this.name = "AlreadyExistsError";
}}
}}
export class PermissionDeniedError extends {base} {{
constructor(message: string) {{
super(message, "PERMISSION_DENIED");
this.name = "PermissionDeniedError";
}}
}}
export class UnauthenticatedError extends {base} {{
constructor(message: string) {{
super(message, "UNAUTHENTICATED");
this.name = "UnauthenticatedError";
}}
}}
export class InvalidParameterError extends {base} {{
constructor(message: string) {{
super(message, "INVALID_PARAMETER_VALUE");
this.name = "InvalidParameterError";
}}
}}
export class RequestLimitError extends {base} {{
constructor(message: string) {{
super(message, "REQUEST_LIMIT_EXCEEDED");
this.name = "RequestLimitError";
}}
}}
export class InternalServerError extends {base} {{
constructor(message: string) {{
super(message, "INTERNAL_ERROR");
this.name = "InternalServerError";
}}
}}
export class ServiceUnavailableError extends {base} {{
constructor(message: string) {{
super(message, "TEMPORARILY_UNAVAILABLE");
this.name = "ServiceUnavailableError";
}}
}}
type ErrorConstructor = new (message: string) => {base};
const ERROR_MAP: Record<string, ErrorConstructor> = {{
RESOURCE_NOT_FOUND: NotFoundError,
RESOURCE_ALREADY_EXISTS: AlreadyExistsError,
PERMISSION_DENIED: PermissionDeniedError,
UNAUTHENTICATED: UnauthenticatedError,
INVALID_PARAMETER_VALUE: InvalidParameterError,
REQUEST_LIMIT_EXCEEDED: RequestLimitError,
INTERNAL_ERROR: InternalServerError,
TEMPORARILY_UNAVAILABLE: ServiceUnavailableError,
}};
/**
* Parse a native NAPI error that may carry a `{prefix}:<CODE>:<message>` prefix
* and re-throw as the appropriate typed subclass of `{base}`.
*/
function parseNativeError(e: unknown): never {{
if (e instanceof Error) {{
const match = e.message.match(/^{prefix}:([^:]+):([\s\S]*)$/);
if (match) {{
const [, code, message] = match;
const Ctor = ERROR_MAP[code] ?? {base};
throw new Ctor(message);
}}
}}
throw e;
}}
// ── end {base} error hierarchy ─────────────────────────────────────────────────────
"#
)
}
pub(crate) fn generate_models_barrel(services: &[ServiceHandler<'_>]) -> String {
let mut paths: Vec<String> = services
.iter()
.map(|s| s.plan.package.replace('.', "/"))
.sorted()
.dedup()
.flat_map(|pkg| {
[
format!("export * from \"./gen/{pkg}/models_pb\";"),
format!("export * from \"./gen/{pkg}/svc_pb\";"),
]
})
.collect();
paths.push(String::new()); paths.join("\n")
}
pub(crate) fn generate_client_ts(services: &[ServiceHandler<'_>]) -> String {
let bindings = services
.first()
.expect("binding output has a service")
.config
.bindings_config();
let sorted: Vec<&ServiceHandler<'_>> = services
.iter()
.sorted_by_key(|s| &s.plan.service_name)
.collect();
let mut out = String::new();
out.push_str(&generate_imports_sorted(&sorted));
out.push('\n');
out.push_str(&generate_error_classes(bindings));
for service in &sorted {
for method in service.methods() {
if let Some(iface) = generate_options_interface(&method) {
out.push_str(&iface);
out.push('\n');
}
}
}
for service in &sorted {
if let Some(class) = generate_resource_client_class(service) {
out.push_str(&class);
out.push('\n');
}
}
out.push_str(&generate_aggregate_client_sorted(&sorted, bindings));
out
}
fn generate_imports_sorted(services: &[&ServiceHandler<'_>]) -> String {
let bindings = services
.first()
.expect("binding output has a service")
.config
.bindings_config();
let napi_aggregate_name = format!("Napi{}", bindings.aggregate_client_name);
let mut type_names: BTreeSet<String> = BTreeSet::new();
for service in services {
if let Some(resource) = service.resource() {
type_names.insert(last_segment(&resource.type_name).to_string());
}
for method in service.methods() {
if !method_is_emitted(service, &method) {
continue;
}
type_names.extend(decoded_type_name(&method));
type_names.extend(
method
.required_parameters()
.filter(|param| is_required_message_body(param))
.filter_map(message_type_name),
);
}
}
let schema_names: BTreeSet<String> = type_names.iter().map(|n| format!("{n}Schema")).collect();
let mut native_classes: Vec<String> = vec![format!("{} as NativeClient", napi_aggregate_name)];
for service in services {
if service.emits_scoped_binding() {
let napi_name = format!("Napi{}", service.client_type());
let native_alias = format!("Native{}", service.client_type());
native_classes.push(format!("{napi_name} as {native_alias}"));
}
}
native_classes.sort();
let type_imports = type_names
.iter()
.map(|t| format!(" {},", ts_type_import_clause(t)))
.collect::<Vec<_>>()
.join("\n");
let schema_imports = schema_names
.iter()
.map(|s| format!(" {s},"))
.collect::<Vec<_>>()
.join("\n");
let native_imports = native_classes
.iter()
.map(|n| format!(" {n},"))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"import {{ fromBinary, toBinary }} from "@bufbuild/protobuf";
import {{
{type_imports}
{schema_imports}
}} from "./models";
import {{
{native_imports}
}} from "./native";
"#
)
}
fn decoded_type_name(method: &MethodHandler<'_>) -> Option<String> {
match &method.plan.request_type {
RequestType::List => Some(
method
.list_output_field()?
.unified_type
.type_ident()
.to_string(),
),
_ => method.output_type().map(|t| t.to_string()),
}
}
fn method_is_emitted(service: &ServiceHandler<'_>, method: &MethodHandler<'_>) -> bool {
match service.binding_mode() {
BindingMode::Flat => true,
BindingMode::Scoped => method.is_scoped_instance_method() || is_root_collection(method),
}
}
fn is_root_collection(method: &MethodHandler<'_>) -> bool {
method.is_collection_method()
&& matches!(method.emit_shape(), EmitShape::List | EmitShape::Create)
}
fn generate_options_interface(method: &MethodHandler<'_>) -> Option<String> {
let optional_params: Vec<&RequestParam> = method
.optional_parameters()
.filter(|p| !p.is_path_param() && is_napi_supported(p))
.collect();
if optional_params.is_empty() {
return None;
}
let interface_name = format!("{}Options", method.plan.metadata.method_name);
let mut fields = String::new();
for param in &optional_params {
let ts_name = param.name().to_case(Case::Camel);
let ts_type = unified_to_typescript(param.field_type());
let ts_type = ts_type.strip_suffix(" | undefined").unwrap_or(&ts_type);
if let Some(doc) = param.documentation() {
let cleaned = doc.trim().replace('\n', "\n * ");
fields.push_str(&format!(" /** {cleaned} */\n"));
}
fields.push_str(&format!(" {ts_name}?: {ts_type};\n"));
}
Some(format!(
"export interface {interface_name} {{\n{fields}}}\n"
))
}
fn generate_resource_client_class(service: &ServiceHandler<'_>) -> Option<String> {
if !service.emits_scoped_binding() {
return None;
}
let resource = service.resource()?;
let type_name = resource
.type_name
.split('.')
.next_back()
.unwrap_or(&resource.type_name);
let client_type = service.client_type().to_string();
let native_type = format!("Native{client_type}");
let mut methods = String::new();
let mode = BindingMode::Scoped;
for method in service.methods().filter(|m| m.is_scoped_instance_method()) {
methods.push_str(&match (&method.plan.request_type, method.emit_shape()) {
(RequestType::Get | RequestType::Update, _) => {
generate_instance_returning_method(&method, type_name, mode)
}
(_, EmitShape::Delete) => generate_resource_delete_method(&method, mode),
_ => generate_output_method(&method, mode),
});
}
let body = if methods.is_empty() {
format!(
r#" /** @internal */
constructor(private readonly inner: {native_type}) {{}}
"#
)
} else {
format!(
r#" private readonly inner: {native_type};
/** @internal */
constructor(inner: {native_type}) {{
this.inner = inner;
}}
{methods}"#
)
};
Some(format!(
r#"export class {client_type} {{
{body}}}
"#
))
}
fn ts_param_split<'a>(
method: &'a MethodHandler<'a>,
mode: BindingMode,
) -> (Vec<&'a RequestParam>, Vec<&'a RequestParam>) {
let (required, optional) = method.param_plan_split(mode);
(
ts_capability_filter(required, true),
ts_capability_filter(optional, false),
)
}
fn ts_collection_param_split<'a>(
method: &'a MethodHandler<'a>,
drop_path: bool,
drop_page_token: bool,
) -> (Vec<&'a RequestParam>, Vec<&'a RequestParam>) {
let required = method
.required_parameters()
.filter(|p| !(drop_path && p.is_path_param()))
.collect();
let optional = method
.optional_parameters()
.filter(|p| !(drop_path && p.is_path_param()))
.filter(|p| !(drop_page_token && p.name() == "page_token"))
.collect();
(
ts_capability_filter(required, true),
ts_capability_filter(optional, false),
)
}
fn ts_capability_filter(
params: Vec<&RequestParam>,
keep_message_bodies: bool,
) -> Vec<&RequestParam> {
params
.into_iter()
.filter(|p| is_napi_supported(p) || (keep_message_bodies && is_required_message_body(p)))
.collect()
}
fn instance_method_name(method: &MethodHandler<'_>, mode: BindingMode) -> String {
match mode {
BindingMode::Scoped => method
.plan
.resource_client_method()
.to_string()
.to_case(Case::Camel),
BindingMode::Flat => method.js_method_name(),
}
}
fn generate_output_method(method: &MethodHandler<'_>, mode: BindingMode) -> String {
match method.output_type() {
Some(output_type) => {
generate_instance_returning_method(method, &output_type.to_string(), mode)
}
None if method.is_existence_check() => generate_existence_check_method(method, mode),
None => generate_resource_delete_method(method, mode),
}
}
fn generate_instance_returning_method(
method: &MethodHandler<'_>,
type_name: &str,
mode: BindingMode,
) -> String {
let schema_name = format!("{type_name}Schema");
let type_ref = ts_type_ident(type_name);
let jsdoc = format_jsdoc(method.plan.metadata.documentation.as_deref(), " ");
let name = instance_method_name(method, mode);
let (required_params, optional_params) = ts_param_split(method, mode);
let spec = MethodCallSpec::build(method, &required_params, &optional_params);
let MethodCallSpec {
full_param_list,
optional_destructure,
all_args,
} = spec;
format!(
r#"{jsdoc} async {name}({full_param_list}): Promise<{type_ref}> {{
{optional_destructure} try {{
return fromBinary({schema_name}, await this.inner.{name}({all_args}));
}} catch (e) {{ throw parseNativeError(e); }}
}}
"#
)
}
fn generate_existence_check_method(method: &MethodHandler<'_>, mode: BindingMode) -> String {
let jsdoc = format_jsdoc(method.plan.metadata.documentation.as_deref(), " ");
let name = instance_method_name(method, mode);
let (required_params, optional_params) = ts_param_split(method, mode);
let MethodCallSpec {
full_param_list,
optional_destructure,
all_args,
} = MethodCallSpec::build(method, &required_params, &optional_params);
format!(
r#"{jsdoc} async {name}({full_param_list}): Promise<boolean> {{
{optional_destructure} try {{
return await this.inner.{name}({all_args});
}} catch (e) {{ throw parseNativeError(e); }}
}}
"#
)
}
fn generate_resource_delete_method(method: &MethodHandler<'_>, mode: BindingMode) -> String {
let jsdoc = format_jsdoc(method.plan.metadata.documentation.as_deref(), " ");
let name = instance_method_name(method, mode);
let (required_params, optional_params) = ts_param_split(method, mode);
let spec = MethodCallSpec::build(method, &required_params, &optional_params);
let MethodCallSpec {
full_param_list,
optional_destructure,
all_args,
} = spec;
format!(
r#"{jsdoc} async {name}({full_param_list}): Promise<void> {{
{optional_destructure} try {{
await this.inner.{name}({all_args});
}} catch (e) {{ throw parseNativeError(e); }}
}}
"#
)
}
fn generate_flat_method(method: &MethodHandler<'_>) -> String {
let mode = BindingMode::Flat;
match method.emit_shape() {
EmitShape::List => {
let mut out = generate_collection_list_method(method, false);
out.push_str(&generate_collection_list_stream_method(method, false));
out
}
EmitShape::Create => generate_collection_create_method(method, false),
EmitShape::Delete => generate_resource_delete_method(method, mode),
EmitShape::GetUpdate => generate_output_method(method, mode),
}
}
fn generate_aggregate_client_sorted(
services: &[&ServiceHandler<'_>],
bindings: &BindingsConfig,
) -> String {
let aggregate_client_name = &bindings.aggregate_client_name;
let mut methods = String::new();
for service in services {
match service.binding_mode() {
BindingMode::Scoped => {
for method in service.methods().filter(is_root_collection) {
if method.emit_shape() == EmitShape::List {
methods.push_str(&generate_collection_list_method(&method, true));
methods.push_str(&generate_collection_list_stream_method(&method, true));
} else {
methods.push_str(&generate_collection_create_method(&method, true));
}
}
if service.supports_scoped_client()
&& let Some(accessor) = generate_resource_accessor(service)
{
methods.push_str(&accessor);
}
}
BindingMode::Flat => {
for method in service.methods() {
methods.push_str(&generate_flat_method(&method));
}
}
}
}
format!(
r#"export class {aggregate_client_name} {{
private readonly inner: NativeClient;
constructor(url: string, token?: string) {{
this.inner = NativeClient.fromUrl(url, token);
}}
{methods}}}
"#
)
}
struct MethodCallSpec {
full_param_list: String,
optional_destructure: String,
all_args: String,
}
impl MethodCallSpec {
fn build(
method: &MethodHandler<'_>,
required_params: &[&RequestParam],
optional_params: &[&RequestParam],
) -> Self {
let options_type = format!("{}Options", method.plan.metadata.method_name);
let required_param_list = required_params
.iter()
.map(|p| {
let ty = match message_type_name(p) {
Some(name) if is_required_message_body(p) => ts_type_ident(&name),
_ => unified_to_typescript(p.field_type()).replace(" | undefined", ""),
};
format!("{}: {}", p.name().to_case(Case::Camel), ty)
})
.collect::<Vec<_>>()
.join(", ");
let has_options = !optional_params.is_empty();
let full_param_list = if has_options {
if required_param_list.is_empty() {
format!("options?: {options_type}")
} else {
format!("{required_param_list}, options?: {options_type}")
}
} else {
required_param_list.clone()
};
let optional_destructure = if has_options {
let fields = optional_params
.iter()
.map(|p| p.name().to_case(Case::Camel))
.collect::<Vec<_>>()
.join(", ");
format!(" const {{ {fields} }} = options || {{}};\n")
} else {
String::new()
};
let mut args: Vec<String> = required_params
.iter()
.map(|p| {
let name = p.name().to_case(Case::Camel);
match message_type_name(p) {
Some(ty) if is_required_message_body(p) => {
format!("Buffer.from(toBinary({ty}Schema, {name}))")
}
_ => name,
}
})
.collect();
for p in optional_params {
args.push(p.name().to_case(Case::Camel));
}
let all_args = args.join(", ");
Self {
full_param_list,
optional_destructure,
all_args,
}
}
}
fn generate_collection_list_method(method: &MethodHandler<'_>, drop_path: bool) -> String {
let jsdoc = format_jsdoc(method.plan.metadata.documentation.as_deref(), " ");
let method_name = method.js_method_name();
let items_field = match method.list_output_field() {
Some(field) => field,
None => return String::new(),
};
let item_type_name = items_field.unified_type.type_ident().to_string();
let schema_name = format!("{item_type_name}Schema");
let item_type_ref = ts_type_ident(&item_type_name);
let (required_params, optional_params) = ts_collection_param_split(method, drop_path, true);
let spec = MethodCallSpec::build(method, &required_params, &optional_params);
let MethodCallSpec {
full_param_list,
optional_destructure,
all_args,
} = spec;
format!(
r#"{jsdoc} async {method_name}({full_param_list}): Promise<{item_type_ref}[]> {{
{optional_destructure} try {{
return (await this.inner.{method_name}({all_args})).map((data) =>
fromBinary({schema_name}, data),
);
}} catch (e) {{ throw parseNativeError(e); }}
}}
"#
)
}
fn generate_collection_list_stream_method(method: &MethodHandler<'_>, drop_path: bool) -> String {
let jsdoc = format_jsdoc(method.plan.metadata.documentation.as_deref(), " ");
let base_method_name = method.js_method_name();
let stream_method_name = format!("{base_method_name}Stream");
let items_field = match method.list_output_field() {
Some(field) => field,
None => return String::new(),
};
let item_type_name = items_field.unified_type.type_ident().to_string();
let schema_name = format!("{item_type_name}Schema");
let item_type_ref = ts_type_ident(&item_type_name);
let (required_params, optional_params) = ts_collection_param_split(method, drop_path, true);
let spec = MethodCallSpec::build(method, &required_params, &optional_params);
let MethodCallSpec {
full_param_list,
optional_destructure,
all_args,
} = spec;
format!(
r#"{jsdoc} async *{stream_method_name}({full_param_list}): AsyncIterable<{item_type_ref}> {{
{optional_destructure} try {{
for await (const data of this.inner.{base_method_name}Stream({all_args})) {{
yield fromBinary({schema_name}, data);
}}
}} catch (e) {{ throw parseNativeError(e); }}
}}
"#
)
}
fn generate_collection_create_method(method: &MethodHandler<'_>, drop_path: bool) -> String {
let output_type = match method.output_type() {
Some(t) => t.to_string(),
None => return generate_void_create_method(method, drop_path),
};
let schema_name = format!("{output_type}Schema");
let output_type_ref = ts_type_ident(&output_type);
let jsdoc = format_jsdoc(method.plan.metadata.documentation.as_deref(), " ");
let method_name = method.js_method_name();
let (required_params, optional_params) = ts_collection_param_split(method, drop_path, false);
let spec = MethodCallSpec::build(method, &required_params, &optional_params);
let MethodCallSpec {
full_param_list,
optional_destructure,
all_args,
} = spec;
format!(
r#"{jsdoc} async {method_name}({full_param_list}): Promise<{output_type_ref}> {{
{optional_destructure} try {{
return fromBinary({schema_name}, await this.inner.{method_name}({all_args}));
}} catch (e) {{ throw parseNativeError(e); }}
}}
"#
)
}
fn generate_void_create_method(method: &MethodHandler<'_>, drop_path: bool) -> String {
let jsdoc = format_jsdoc(method.plan.metadata.documentation.as_deref(), " ");
let method_name = method.js_method_name();
let (required_params, optional_params) = ts_collection_param_split(method, drop_path, false);
let spec = MethodCallSpec::build(method, &required_params, &optional_params);
let MethodCallSpec {
full_param_list,
optional_destructure,
all_args,
} = spec;
format!(
r#"{jsdoc} async {method_name}({full_param_list}): Promise<void> {{
{optional_destructure} try {{
await this.inner.{method_name}({all_args});
}} catch (e) {{ throw parseNativeError(e); }}
}}
"#
)
}
fn generate_resource_accessor(service: &ServiceHandler<'_>) -> Option<String> {
let spec = service.accessor_spec()?;
let method_name = spec.singular.to_case(Case::Camel);
let client_type = service.client_type().to_string();
let param_list = spec
.params
.iter()
.map(|p| format!("{}: string", p.to_case(Case::Camel)))
.collect::<Vec<_>>()
.join(", ");
let arg_list = spec
.params
.iter()
.map(|p| p.to_case(Case::Camel))
.collect::<Vec<_>>()
.join(", ");
Some(format!(
r#" {method_name}({param_list}): {client_type} {{
return new {client_type}(this.inner.{method_name}({arg_list}));
}}
"#
))
}