use zynk_schema::{ApiGraph, Endpoint, EndpointKind, Field, Param, TypeKind, TypeRef};
use crate::{lowering, naming};
pub fn print_api(graph: &ApiGraph) -> String {
let mut printer = TsPrinter::new();
let imports = import_values(graph);
let type_imports = import_types(graph);
printer.line("// Generated by zynk-gen-ts. Do not edit.");
printer.line(format!(
"import {{ {} }} from \"./_internal\";",
imports.join(", ")
));
if !type_imports.is_empty() {
printer.line(format!(
"import type {{ {} }} from \"./_internal\";",
type_imports.join(", ")
));
}
printer.blank_line();
printer.line("export { initBridge, BridgeRequestError };");
if !type_imports.is_empty() {
printer.line(format!("export type {{ {} }};", type_imports.join(", ")));
}
if !graph.models.is_empty() {
printer.blank_line();
for model in graph.models.values() {
if let Some(doc) = &model.doc {
printer.doc(doc);
}
printer.line(format!(
"export interface {} {{",
naming::to_pascal_case(&model.name)
));
printer.indented(|printer| {
for field in &model.fields {
print_field(printer, field, graph);
}
});
printer.line("}");
printer.blank_line();
}
}
let endpoints = graph.endpoints.values().collect::<Vec<_>>();
if !endpoints.is_empty() {
for endpoint in endpoints {
print_endpoint(&mut printer, endpoint, graph);
printer.blank_line();
}
}
printer.finish()
}
fn import_values(graph: &ApiGraph) -> Vec<&'static str> {
let mut imports = vec!["initBridge", "BridgeRequestError"];
if has_kind(graph, EndpointKind::Rpc) {
imports.push("request");
}
if has_kind(graph, EndpointKind::Channel) {
imports.push("createChannel");
}
if has_kind(graph, EndpointKind::Upload) {
imports.push("createUpload");
}
if has_kind(graph, EndpointKind::Static) || has_kind(graph, EndpointKind::Ws) {
imports.push("getBaseUrl");
}
imports
}
fn import_types(graph: &ApiGraph) -> Vec<&'static str> {
let mut imports = vec!["BridgeError", "UploadProgressEvent"];
if has_kind(graph, EndpointKind::Channel) {
imports.push("BridgeChannel");
}
if has_kind(graph, EndpointKind::Upload) {
imports.push("UploadHandle");
}
imports
}
fn has_kind(graph: &ApiGraph, kind: EndpointKind) -> bool {
graph
.endpoints
.values()
.any(|endpoint| endpoint.kind == kind)
}
fn print_field(printer: &mut TsPrinter, field: &Field, graph: &ApiGraph) {
if let Some(doc) = &field.doc {
printer.doc(doc);
}
let ty = optional_nullable_type(
lowering::lower_required_with_graph(&field.ty, graph),
false,
field.nullable || field.ty.nullable,
);
let optional = field.optional || !field.required || field.ty.optional;
let marker = if optional { "?" } else { "" };
printer.line(format!("{}{marker}: {ty};", field.wire_name));
}
fn print_endpoint(printer: &mut TsPrinter, endpoint: &Endpoint, graph: &ApiGraph) {
match endpoint.kind {
EndpointKind::Rpc => print_rpc(printer, endpoint, graph),
EndpointKind::Channel => print_channel(printer, endpoint, graph),
EndpointKind::Upload => print_upload(printer, endpoint, graph),
EndpointKind::Static => print_static(printer, endpoint, graph),
EndpointKind::Ws => print_websocket(printer, endpoint, graph),
}
}
fn print_rpc(printer: &mut TsPrinter, endpoint: &Endpoint, graph: &ApiGraph) {
print_doc(printer, endpoint);
let fn_name = naming::to_camel_case(&endpoint.name);
let return_ty = return_type(&endpoint.returns, graph);
if endpoint.params.is_empty() {
printer.line(format!(
"export async function {fn_name}(): Promise<{return_ty}> {{"
));
} else {
printer.line(format!(
"export async function {fn_name}(args: {}): Promise<{return_ty}> {{",
params_object_type(&endpoint.params, graph)
));
}
printer.indented(|printer| {
let args = args_object(&endpoint.params, graph);
if returns_model_like(&endpoint.returns) {
printer.line(format!(
"const _r = await request<any>(\"{}\", {args});",
endpoint.name
));
printer.line(format!(
"return {};",
response_mapping(&endpoint.returns, "_r", graph)
));
} else {
printer.line(format!(
"return request<{return_ty}>(\"{}\", {args});",
endpoint.name
));
}
});
printer.line("}");
}
fn print_channel(printer: &mut TsPrinter, endpoint: &Endpoint, graph: &ApiGraph) {
print_doc(printer, endpoint);
let fn_name = naming::to_camel_case(&endpoint.name);
let item_ty = endpoint
.channel_item
.as_ref()
.map(|ty| {
optional_nullable_type(
lowering::lower_required_with_graph(ty, graph),
false,
ty.nullable,
)
})
.unwrap_or_else(|| "unknown".to_string());
if endpoint.params.is_empty() {
printer.line(format!(
"export function {fn_name}(): BridgeChannel<{item_ty}> {{"
));
} else {
printer.line(format!(
"export function {fn_name}(args: {}): BridgeChannel<{item_ty}> {{",
params_object_type(&endpoint.params, graph)
));
}
printer.indented(|printer| {
printer.line(format!(
"return createChannel<{item_ty}>(\"{}\", {});",
endpoint.name,
args_object(&endpoint.params, graph)
));
});
printer.line("}");
}
fn print_upload(printer: &mut TsPrinter, endpoint: &Endpoint, graph: &ApiGraph) {
print_doc(printer, endpoint);
let fn_name = naming::to_camel_case(&endpoint.name);
let return_ty = return_type(&endpoint.returns, graph);
let params_ty = upload_params_object_type(endpoint, graph);
let files_expr = if endpoint.multi_file {
"args.files"
} else {
"[args.file]"
};
printer.line(format!(
"export function {fn_name}(args: {params_ty}): UploadHandle<{return_ty}> {{"
));
printer.indented(|printer| {
if returns_model_like(&endpoint.returns) {
printer.line(format!(
"const handle = createUpload<any>(\"{}\", {files_expr}, {});",
endpoint.name,
args_object(&endpoint.params, graph)
));
printer.line("return {");
printer.indented(|printer| {
printer.line(format!(
"promise: handle.promise.then((_r: any) => ({})),",
response_mapping(&endpoint.returns, "_r", graph)
));
printer.line("abort: () => handle.abort(),");
printer.line(format!(
"onProgress: (callback) => {{ handle.onProgress(callback); return handle as unknown as UploadHandle<{return_ty}>; }},"
));
});
printer.line("};");
} else {
printer.line(format!(
"return createUpload<{return_ty}>(\"{}\", {files_expr}, {});",
endpoint.name,
args_object(&endpoint.params, graph)
));
}
});
printer.line("}");
}
fn print_static(printer: &mut TsPrinter, endpoint: &Endpoint, graph: &ApiGraph) {
print_doc(printer, endpoint);
let fn_name = format!("{}Url", naming::to_camel_case(&endpoint.name));
if endpoint.params.is_empty() {
printer.line(format!("export function {fn_name}(): string {{"));
} else {
printer.line(format!(
"export function {fn_name}(args: {}): string {{",
params_object_type(&endpoint.params, graph)
));
}
printer.indented(|printer| {
if endpoint.params.is_empty() {
printer.line(format!(
"return `${{getBaseUrl()}}/static/{}`;",
endpoint.name
));
} else {
printer.line("const params = new URLSearchParams();");
for param in &endpoint.params {
let name = ¶m.wire_name;
if param_optional(param) {
printer.line(format!(
"if (args.{name} !== undefined) params.set(\"{}\", String(args.{name}));",
param.source_name
));
} else {
printer.line(format!(
"params.set(\"{}\", String(args.{name}));",
param.source_name
));
}
}
printer.line(format!(
"return `${{getBaseUrl()}}/static/{}?${{params}}`;",
endpoint.name
));
}
});
printer.line("}");
}
fn print_websocket(printer: &mut TsPrinter, endpoint: &Endpoint, graph: &ApiGraph) {
let pascal = naming::to_pascal_case(&endpoint.name);
let server_events = format!("{pascal}ServerEvents");
let client_events = format!("{pascal}ClientEvents");
let class_name = format!("{pascal}Socket");
printer.line(format!("export interface {server_events} {{"));
printer.indented(|printer| {
for event in &endpoint.server_events {
printer.line(format!(
"{}: {};",
event.source_name,
event_type(&event.ty, graph)
));
}
});
printer.line("}");
printer.blank_line();
printer.line(format!("export interface {client_events} {{"));
printer.indented(|printer| {
for event in &endpoint.client_events {
printer.line(format!(
"{}: {};",
event.source_name,
event_type(&event.ty, graph)
));
}
});
printer.line("}");
printer.blank_line();
print_doc(printer, endpoint);
printer.line(format!("export class {class_name} {{"));
printer.indented(|printer| {
printer.line("private ws: WebSocket | null = null;");
printer.line("private listeners: Map<string, Set<(data: unknown) => void>> = new Map();");
printer.line("private connectionListeners: Set<() => void> = new Set();");
printer.line("private disconnectionListeners: Set<(event: CloseEvent) => void> = new Set();");
printer.line("private errorListeners: Set<(error: Event) => void> = new Set();");
printer.blank_line();
printer.line("connect(): void {");
printer.indented(|printer| {
printer.line("const baseUrl = getBaseUrl().replace(/^http/, 'ws');");
printer.line(format!("this.ws = new WebSocket(`${{baseUrl}}/ws/{}`);", endpoint.name));
printer.line("this.ws.onopen = () => this.connectionListeners.forEach((cb) => cb());");
printer.line("this.ws.onclose = (event) => this.disconnectionListeners.forEach((cb) => cb(event));");
printer.line("this.ws.onerror = (error) => this.errorListeners.forEach((cb) => cb(error));");
printer.line("this.ws.onmessage = (event) => {");
printer.indented(|printer| {
printer.line("const message = JSON.parse(event.data);");
printer.line("const callbacks = this.listeners.get(message.event);");
printer.line("callbacks?.forEach((cb) => cb(message.data));");
});
printer.line("};");
});
printer.line("}");
printer.blank_line();
printer.line("disconnect(): void {");
printer.indented(|printer| {
printer.line("this.ws?.close();");
printer.line("this.ws = null;");
});
printer.line("}");
printer.blank_line();
printer.line("get isConnected(): boolean {");
printer.indented(|printer| printer.line("return this.ws?.readyState === WebSocket.OPEN;"));
printer.line("}");
printer.blank_line();
printer.line(format!("on<K extends keyof {server_events}>(event: K, callback: (data: {server_events}[K]) => void): () => void {{"));
printer.indented(|printer| {
printer.line("const eventName = event as string;");
printer.line("if (!this.listeners.has(eventName)) this.listeners.set(eventName, new Set());");
printer.line("const cb = callback as (data: unknown) => void;");
printer.line("this.listeners.get(eventName)!.add(cb);");
printer.line("return () => this.listeners.get(eventName)?.delete(cb);");
});
printer.line("}");
printer.blank_line();
printer.line("private sendRaw(event: string, data: unknown): void {");
printer.indented(|printer| {
printer.line("if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {");
printer.indented(|printer| printer.line("throw new Error('[Zynk] WebSocket is not connected');"));
printer.line("}");
printer.line("this.ws.send(JSON.stringify({ event, data }));");
});
printer.line("}");
printer.blank_line();
printer.line(format!("send<K extends keyof {client_events}>(event: K, data: {client_events}[K]): void {{"));
printer.indented(|printer| {
printer.line("this.sendRaw(event as string, data);");
});
printer.line("}");
printer.blank_line();
printer.line("onConnect(callback: () => void): () => void {");
printer.indented(|printer| {
printer.line("this.connectionListeners.add(callback);");
printer.line("return () => this.connectionListeners.delete(callback);");
});
printer.line("}");
printer.blank_line();
printer.line("onDisconnect(callback: (event: CloseEvent) => void): () => void {");
printer.indented(|printer| {
printer.line("this.disconnectionListeners.add(callback);");
printer.line("return () => this.disconnectionListeners.delete(callback);");
});
printer.line("}");
printer.blank_line();
printer.line("onError(callback: (error: Event) => void): () => void {");
printer.indented(|printer| {
printer.line("this.errorListeners.add(callback);");
printer.line("return () => this.errorListeners.delete(callback);");
});
printer.line("}");
for event in &endpoint.server_events {
let method = format!("on{}", naming::to_pascal_case(&event.source_name));
let ty = event_type(&event.ty, graph);
printer.blank_line();
printer.line(format!("{method}(callback: (data: {ty}) => void): () => void {{"));
printer.indented(|printer| {
printer.line(format!("return this.on(\"{}\", callback);", event.source_name));
});
printer.line("}");
}
for event in &endpoint.client_events {
let method = format!("send{}", naming::to_pascal_case(&event.source_name));
let ty = event_type(&event.ty, graph);
let payload = request_mapping(&event.ty, "data", graph);
printer.blank_line();
printer.line(format!("{method}(data: {ty}): void {{"));
printer.indented(|printer| {
printer.line(format!("this.sendRaw(\"{}\", {payload});", event.source_name));
});
printer.line("}");
}
});
printer.line("}");
printer.blank_line();
printer.line(format!(
"export function create{pascal}Socket(): {class_name} {{"
));
printer.indented(|printer| printer.line(format!("return new {class_name}();")));
printer.line("}");
}
fn print_doc(printer: &mut TsPrinter, endpoint: &Endpoint) {
if let Some(doc) = &endpoint.doc {
printer.doc(doc);
}
}
fn params_object_type(params: &[Param], graph: &ApiGraph) -> String {
format!(
"{{ {} }}",
params
.iter()
.map(|param| {
let marker = if param_optional(param) { "?" } else { "" };
format!(
"{}{marker}: {}",
param.wire_name,
optional_nullable_type(
lowering::lower_required_with_graph(¶m.ty, graph),
false,
param.ty.nullable,
)
)
})
.collect::<Vec<_>>()
.join("; ")
)
}
fn upload_params_object_type(endpoint: &Endpoint, graph: &ApiGraph) -> String {
let mut fields = Vec::new();
if endpoint.multi_file {
fields.push("files: File[]".to_string());
} else {
fields.push("file: File".to_string());
}
fields.extend(endpoint.params.iter().map(|param| {
let marker = if param_optional(param) { "?" } else { "" };
format!(
"{}{marker}: {}",
param.wire_name,
optional_nullable_type(
lowering::lower_required_with_graph(¶m.ty, graph),
false,
param.ty.nullable,
)
)
}));
format!("{{ {} }}", fields.join("; "))
}
fn param_optional(param: &Param) -> bool {
!param.required || param.ty.optional
}
fn args_object(params: &[Param], graph: &ApiGraph) -> String {
if params.is_empty() {
return "{}".to_string();
}
format!(
"{{ {} }}",
params
.iter()
.map(|param| format!(
"{}: {}",
param.source_name,
request_mapping(¶m.ty, &format!("args.{}", param.wire_name), graph)
))
.collect::<Vec<_>>()
.join(", ")
)
}
fn map_variable(depth: usize) -> String {
match depth {
0 => "_item".to_string(),
1 => "_inner".to_string(),
2 => "_deep".to_string(),
depth => format!("_item{depth}"),
}
}
fn return_type(ty: &TypeRef, graph: &ApiGraph) -> String {
let base = match ty.kind {
TypeKind::Void => lowering::lower_return(ty),
_ => lowering::lower_required_with_graph(ty, graph),
};
optional_nullable_type(base, ty.optional, ty.nullable)
}
fn event_type(ty: &TypeRef, graph: &ApiGraph) -> String {
optional_nullable_type(
lowering::lower_required_with_graph(ty, graph),
ty.optional,
ty.nullable,
)
}
fn optional_nullable_type(base: String, optional: bool, nullable: bool) -> String {
lowering::apply_optional_nullable(base, optional, nullable)
}
fn returns_model_like(ty: &TypeRef) -> bool {
match ty.kind {
TypeKind::Model => true,
TypeKind::Array => ty.inner.first().is_some_and(returns_model_like),
_ => false,
}
}
fn request_mapping(ty: &TypeRef, expr: &str, graph: &ApiGraph) -> String {
request_mapping_at_depth(ty, expr, graph, 0)
}
fn request_mapping_at_depth(ty: &TypeRef, expr: &str, graph: &ApiGraph, depth: usize) -> String {
match ty.kind {
TypeKind::Model => model_request_mapping(ty.name.as_deref(), expr, graph, depth),
TypeKind::Array if ty.inner.first().is_some_and(returns_model_like) => {
let item = ty.inner.first().expect("checked by is_some_and");
let variable = map_variable(depth);
let item_mapping = request_mapping_at_depth(item, &variable, graph, depth + 1);
format!("{expr}.map(({variable}) => ({item_mapping}))")
}
_ => expr.to_string(),
}
}
fn response_mapping(ty: &TypeRef, expr: &str, graph: &ApiGraph) -> String {
response_mapping_at_depth(ty, expr, graph, 0)
}
fn response_mapping_at_depth(ty: &TypeRef, expr: &str, graph: &ApiGraph, depth: usize) -> String {
match ty.kind {
TypeKind::Model => model_mapping(ty.name.as_deref(), expr, graph, depth),
TypeKind::Array if ty.inner.first().is_some_and(returns_model_like) => {
let item = ty.inner.first().expect("checked by is_some_and");
let variable = map_variable(depth);
let item_mapping = response_mapping_at_depth(item, &variable, graph, depth + 1);
format!("{expr}.map(({variable}: any) => ({item_mapping}))")
}
_ => expr.to_string(),
}
}
fn model_request_mapping(name: Option<&str>, expr: &str, graph: &ApiGraph, depth: usize) -> String {
let Some(name) = name else {
return expr.to_string();
};
let Some(model) = graph.models.get(name) else {
return expr.to_string();
};
let fields = model
.fields
.iter()
.map(|field| {
format!(
"{}: {}",
field.source_name,
request_mapping_at_depth(
&field.ty,
&format!("{expr}.{}", field.wire_name),
graph,
depth
)
)
})
.collect::<Vec<_>>()
.join(", ");
format!("{{ {fields} }}")
}
fn model_mapping(name: Option<&str>, expr: &str, graph: &ApiGraph, depth: usize) -> String {
let Some(name) = name else {
return expr.to_string();
};
let Some(model) = graph.models.get(name) else {
return expr.to_string();
};
let fields = model
.fields
.iter()
.map(|field| {
format!(
"{}: {}",
field.wire_name,
response_mapping_at_depth(
&field.ty,
&format!("{expr}.{}", field.source_name),
graph,
depth
)
)
})
.collect::<Vec<_>>()
.join(", ");
format!("{{ {fields} }}")
}
#[derive(Debug, Default)]
pub struct TsPrinter {
output: String,
indent: usize,
}
impl TsPrinter {
pub fn new() -> Self {
Self::default()
}
pub fn line(&mut self, line: impl AsRef<str>) {
let line = line.as_ref();
if !line.is_empty() {
for _ in 0..self.indent {
self.output.push_str(" ");
}
self.output.push_str(line);
}
self.output.push('\n');
}
pub fn blank_line(&mut self) {
self.output.push('\n');
}
pub fn doc(&mut self, doc: &str) {
self.line("/**");
for line in doc.trim().lines() {
self.line(format!(" * {}", line.trim()));
}
self.line(" */");
}
pub fn indented(&mut self, write: impl FnOnce(&mut Self)) {
self.indent += 1;
write(self);
self.indent -= 1;
}
pub fn finish(self) -> String {
self.output
}
}
#[cfg(test)]
mod tests {
use zynk_schema::{ApiGraph, Endpoint, EndpointKind, EnumDef, Field, ModelDef, TypeRef, Value};
use super::{print_api, TsPrinter};
macro_rules! json {
($value:literal) => {
Value::from($value)
};
}
#[test]
fn printer_indents_blocks() {
let mut printer = TsPrinter::new();
printer.line("export interface User {");
printer.indented(|printer| printer.line("name: string;"));
printer.line("}");
assert_eq!(
printer.finish(),
"export interface User {\n name: string;\n}\n"
);
}
#[test]
fn prints_representative_model_with_literal_enum_optional_nullable() {
let mut graph = ApiGraph::new();
let mut enum_def = EnumDef::new("priority");
enum_def.values = vec![json!("low"), json!("high")];
graph.insert_enum(enum_def);
let mut model = ModelDef::new("task");
model.doc = Some("A task model.".to_string());
model.fields.push(Field::new(
"title",
"title",
TypeRef::primitive("string"),
true,
));
let mut literal = TypeRef::literal(json!("todo"));
literal.optional = true;
model
.fields
.push(Field::new("state", "state", literal, false));
let mut maybe_priority = TypeRef::enum_ref("priority");
maybe_priority.nullable = true;
let mut field = Field::new("priority", "priority", maybe_priority, true);
field.nullable = true;
model.fields.push(field);
graph.insert_model(model);
let output = print_api(&graph);
assert!(output.contains("/**\n * A task model.\n */\nexport interface Task"));
assert!(output.contains(" title: string;"));
assert!(output.contains(" state?: \"todo\";"));
assert!(output.contains(" priority: \"low\" | \"high\" | null;"));
assert!(!output.contains("| undefined"));
}
#[test]
fn fields_and_params_reuse_lowering_optional_nullable_helper() {
let mut graph = ApiGraph::new();
let mut model = ModelDef::new("maybe_model");
let mut maybe_name = TypeRef::union(vec![
TypeRef::primitive("string"),
TypeRef::primitive("null"),
]);
maybe_name.nullable = true;
let mut field = Field::new("maybe_name", "maybeName", maybe_name, true);
field.nullable = true;
model.fields.push(field);
graph.insert_model(model);
let mut endpoint = Endpoint::new(
"update_maybe",
EndpointKind::Rpc,
TypeRef::primitive("string"),
);
let mut param_ty = TypeRef::union(vec![
TypeRef::primitive("string"),
TypeRef::primitive("null"),
]);
param_ty.nullable = true;
endpoint.params.push(zynk_schema::Param::new(
"maybe_name",
"maybeName",
param_ty,
true,
));
graph.insert_endpoint(endpoint);
let output = print_api(&graph);
assert!(output.contains("maybeName: string | null;"));
assert!(output.contains("updateMaybe(args: { maybeName: string | null })"));
assert!(!output.contains("null | null"));
assert!(!output.contains("undefined | undefined"));
}
#[test]
fn nested_response_arrays_use_unique_map_variables() {
let mut graph = ApiGraph::new();
let mut label = ModelDef::new("label");
label.fields.push(Field::new(
"label_id",
"labelId",
TypeRef::primitive("number"),
true,
));
graph.insert_model(label);
let mut task = ModelDef::new("task");
task.fields.push(Field::new(
"task_id",
"taskId",
TypeRef::primitive("number"),
true,
));
task.fields.push(Field::new(
"labels",
"labels",
TypeRef::array(TypeRef::model("label")),
true,
));
graph.insert_model(task);
let endpoint = Endpoint::new(
"list_tasks",
EndpointKind::Rpc,
TypeRef::array(TypeRef::model("task")),
);
graph.insert_endpoint(endpoint);
let output = print_api(&graph);
assert!(output.contains("return _r.map((_item: any) => ({ taskId: _item.task_id, labels: _item.labels.map((_inner: any) => ({ labelId: _inner.label_id })) }));"));
assert!(!output.contains("labels: _item.labels.map((_item: any)"));
}
#[test]
fn emits_each_endpoint_kind_with_docs_helpers_and_consistent_params() {
let mut graph = ApiGraph::new();
let mut model = ModelDef::new("simple_model");
model.doc = Some("Simple response model.".to_string());
model.fields.push(Field::new(
"item_id",
"itemId",
TypeRef::primitive("number"),
true,
));
let mut note = Field::new("note", "note", TypeRef::primitive("string"), true);
note.optional = true;
model.fields.push(note);
let mut maybe_label = Field::new("label", "label", TypeRef::primitive("string"), true);
maybe_label.nullable = true;
model.fields.push(maybe_label);
let mut optional_maybe_tag = Field::new("tag", "tag", TypeRef::primitive("string"), true);
optional_maybe_tag.optional = true;
optional_maybe_tag.nullable = true;
model.fields.push(optional_maybe_tag);
graph.insert_model(model);
let mut rpc = zynk_schema::Endpoint::new(
"get_item",
zynk_schema::EndpointKind::Rpc,
TypeRef::model("simple_model"),
);
rpc.doc = Some("Fetch an item.".to_string());
rpc.params.push(zynk_schema::Param::new(
"item_id",
"itemId",
TypeRef::primitive("number"),
true,
));
rpc.params.push(zynk_schema::Param::new(
"include_meta",
"includeMeta",
TypeRef::primitive("boolean"),
false,
));
graph.insert_endpoint(rpc);
let mut channel = zynk_schema::Endpoint::new(
"watch_items",
zynk_schema::EndpointKind::Channel,
TypeRef::void(),
);
channel.doc = Some("Stream items.".to_string());
channel.channel_item = Some(TypeRef::model("simple_model"));
graph.insert_endpoint(channel);
let mut upload = zynk_schema::Endpoint::new(
"upload_file",
zynk_schema::EndpointKind::Upload,
TypeRef::model("simple_model"),
);
upload.file_param = Some("file".to_string());
upload.params.push(zynk_schema::Param::new(
"item_id",
"itemId",
TypeRef::primitive("number"),
true,
));
graph.insert_endpoint(upload);
let mut static_endpoint = zynk_schema::Endpoint::new(
"download_report",
zynk_schema::EndpointKind::Static,
TypeRef::primitive("string"),
);
static_endpoint.params.push(zynk_schema::Param::new(
"report_id",
"reportId",
TypeRef::primitive("string"),
true,
));
graph.insert_endpoint(static_endpoint);
let mut ws = zynk_schema::Endpoint::new(
"chat_socket",
zynk_schema::EndpointKind::Ws,
TypeRef::void(),
);
ws.server_events.push(zynk_schema::Param::new(
"new_message",
"newMessage",
TypeRef::model("simple_model"),
true,
));
ws.client_events.push(zynk_schema::Param::new(
"send_message",
"sendMessage",
TypeRef::model("simple_model"),
true,
));
graph.insert_endpoint(ws);
let output = print_api(&graph);
assert!(output.contains("import { initBridge, BridgeRequestError, request, createChannel, createUpload, getBaseUrl } from \"./_internal\";"));
assert!(output.contains("import type { BridgeError, UploadProgressEvent, BridgeChannel, UploadHandle } from \"./_internal\";"));
assert!(output.contains("export { initBridge, BridgeRequestError };"));
assert!(output.contains(
"export type { BridgeError, UploadProgressEvent, BridgeChannel, UploadHandle };"
));
assert!(output.contains("/**\n * Fetch an item.\n */\nexport async function getItem(args: { itemId: number; includeMeta?: boolean }): Promise<SimpleModel>"));
assert!(output.contains(
"request<any>(\"get_item\", { item_id: args.itemId, include_meta: args.includeMeta })"
));
assert!(output.contains("itemId: _r.item_id"));
assert!(output.contains(
"/**\n * Stream items.\n */\nexport function watchItems(): BridgeChannel<SimpleModel>"
));
assert!(output.contains("return createChannel<SimpleModel>(\"watch_items\", {});"));
assert!(output.contains("export function uploadFile(args: { file: File; itemId: number }): UploadHandle<SimpleModel>"));
assert!(output.contains(
"const handle = createUpload<any>(\"upload_file\", [args.file], { item_id: args.itemId });"
));
assert!(output.contains(
"promise: handle.promise.then((_r: any) => ({ itemId: _r.item_id, note: _r.note, label: _r.label, tag: _r.tag })),"
));
assert!(output
.contains("export function downloadReportUrl(args: { reportId: string }): string"));
assert!(output.contains("params.set(\"report_id\", String(args.reportId));"));
assert!(output.contains("note?: string;"));
assert!(output.contains("label: string | null;"));
assert!(output.contains("tag?: string | null;"));
assert!(output.contains("export interface ChatSocketServerEvents"));
assert!(output.contains("new_message: SimpleModel;"));
assert!(output.contains("export class ChatSocketSocket"));
assert!(output.contains("sendSendMessage(data: SimpleModel): void"));
assert!(output.contains(
"this.sendRaw(\"send_message\", { item_id: data.itemId, note: data.note, label: data.label, tag: data.tag });"
));
assert!(!output.contains("as unknown as SimpleModel"));
assert!(!output.contains("?: boolean | undefined"));
}
}