use zynk_schema::{ApiGraph, Endpoint, EndpointKind, Param, TypeKind, TypeRef};
use crate::options::{EffectGeneratorOptions, Surface};
use crate::{lowering, types::TypeExpr};
pub fn print_api(graph: &ApiGraph, options: &EffectGeneratorOptions) -> String {
let mut sections = vec![
"/* Auto-generated by Zynk Effect connector - DO NOT EDIT */".to_string(),
effect_import(graph),
internal_import(graph, options),
public_re_export(graph, options),
];
let schemas = lowering::render_model_schemas(graph);
if !schemas.is_empty() {
sections.push(format!(
"// ============ Schemas ============\n\n{}",
schemas.join("\n\n")
));
}
let commands = endpoints_by_kind(graph, EndpointKind::Rpc)
.into_iter()
.map(|endpoint| emit_rpc(endpoint, graph, options.resolve(EndpointKind::Rpc)))
.collect::<Vec<_>>();
if !commands.is_empty() {
sections.push(format!(
"// ============ Commands ============\n\n{}",
commands.join("\n\n")
));
}
let channels = endpoints_by_kind(graph, EndpointKind::Channel)
.into_iter()
.map(|endpoint| emit_channel(endpoint, graph, options.resolve(EndpointKind::Channel)))
.collect::<Vec<_>>();
if !channels.is_empty() {
sections.push(format!(
"// ============ Channels ============\n\n{}",
channels.join("\n\n")
));
}
let uploads = endpoints_by_kind(graph, EndpointKind::Upload)
.into_iter()
.map(|endpoint| emit_upload(endpoint, graph, options.resolve(EndpointKind::Upload)))
.collect::<Vec<_>>();
if !uploads.is_empty() {
sections.push(format!(
"// ============ Uploads ============\n\n{}",
uploads.join("\n\n")
));
}
let statics = endpoints_by_kind(graph, EndpointKind::Static)
.into_iter()
.map(|endpoint| emit_static(endpoint, graph, options.resolve(EndpointKind::Static)))
.collect::<Vec<_>>();
if !statics.is_empty() {
sections.push(format!(
"// ============ Static Files ============\n\n{}",
statics.join("\n\n")
));
}
let sockets = endpoints_by_kind(graph, EndpointKind::Ws)
.into_iter()
.map(|endpoint| emit_websocket(endpoint, graph))
.collect::<Vec<_>>();
if !sockets.is_empty() {
sections.push(format!(
"// ============ WebSockets ============\n\n{}",
sockets.join("\n\n")
));
}
format!("{}\n", sections.join("\n\n"))
}
fn effect_import(graph: &ApiGraph) -> String {
let mut lines = vec!["import {", " Effect,", " Schema,"];
if needs_stream(graph) {
lines.push(" Stream,");
}
lines.push("} from \"effect\"");
lines.join("\n")
}
fn internal_import(graph: &ApiGraph, options: &EffectGeneratorOptions) -> String {
let mut imports = vec![" callCommand,"];
if has_kind(graph, EndpointKind::Channel) {
imports.push(" callChannel,");
}
if has_kind(graph, EndpointKind::Upload) {
imports.push(" callUpload,");
}
if has_kind(graph, EndpointKind::Static) {
imports.push(" buildStaticUrl,");
}
if has_kind(graph, EndpointKind::Ws) {
imports.push(" openWebSocket,");
}
if needs_promise(graph, options) {
imports.push(" runPromise,");
}
if has_kind(graph, EndpointKind::Channel)
&& options.resolve(EndpointKind::Channel) == Surface::Promise
{
imports.push(" toAsyncIterable,");
}
imports.push(" type CallOptions,");
if has_kind(graph, EndpointKind::Upload) {
imports.push(" type UploadOptions,");
}
imports.push(" type ZynkClient,");
imports.push(" type ZynkError,");
if has_kind(graph, EndpointKind::Ws) {
imports.push(" ZynkNetworkError,");
}
format!(
"import {{\n{}\n}} from \"./_effect_internal\"",
imports.join("\n")
)
}
fn public_re_export(graph: &ApiGraph, options: &EffectGeneratorOptions) -> String {
let mut exports = vec![
" ZynkClient,",
" initZynk,",
" layerZynkClient,",
" disposeZynk,",
" type CallOptions,",
];
if has_kind(graph, EndpointKind::Upload) {
exports.push(" type UploadOptions,");
exports.push(" type UploadProgressEvent,");
}
exports.extend([
" type RetryOptions,",
" type BackoffStrategy,",
" type ZynkClientConfig,",
" type ZynkError,",
" ZynkNetworkError,",
" ZynkHttpError,",
" ZynkTimeoutError,",
" ZynkDecodeError,",
" ZynkAbortError,",
" ZynkStreamError,",
" ZynkUploadError,",
]);
if needs_promise(graph, options) {
exports.push(" runPromise,");
}
if has_kind(graph, EndpointKind::Channel) {
exports.push(" toAsyncIterable,");
}
format!(
"export {{\n{}\n}} from \"./_effect_internal\"",
exports.join("\n")
)
}
fn endpoints_by_kind(graph: &ApiGraph, kind: EndpointKind) -> Vec<&Endpoint> {
graph
.endpoints
.values()
.filter(|endpoint| endpoint.kind == kind)
.collect()
}
fn has_kind(graph: &ApiGraph, kind: EndpointKind) -> bool {
graph
.endpoints
.values()
.any(|endpoint| endpoint.kind == kind)
}
fn needs_stream(graph: &ApiGraph) -> bool {
has_kind(graph, EndpointKind::Channel) || has_kind(graph, EndpointKind::Ws)
}
fn needs_promise(_graph: &ApiGraph, options: &EffectGeneratorOptions) -> bool {
[
EndpointKind::Rpc,
EndpointKind::Channel,
EndpointKind::Upload,
EndpointKind::Static,
]
.into_iter()
.any(|kind| options.resolve(kind) == Surface::Promise)
}
fn emit_rpc(endpoint: &Endpoint, graph: &ApiGraph, surface: Surface) -> String {
let fn_name = camel(&endpoint.name);
let response = type_expr(&endpoint.returns, graph);
let params_type = params_object(&endpoint.params, graph);
let args_payload = args_payload(&endpoint.params, graph);
let mut lines = doc_block(endpoint.doc.as_deref());
match (surface, params_type.as_str()) {
(Surface::Effect, "void") => lines.push(format!(
"export const {fn_name} = (options?: CallOptions): Effect.Effect<{}, ZynkError, ZynkClient> =>",
response.ts
)),
(Surface::Effect, _) => lines.push(format!(
"export const {fn_name} = (args: {params_type}, options?: CallOptions): Effect.Effect<{}, ZynkError, ZynkClient> =>",
response.ts
)),
(Surface::Promise, "void") => lines.push(format!(
"export const {fn_name} = (options?: CallOptions): Promise<{}> =>",
response.ts
)),
(Surface::Promise, _) => lines.push(format!(
"export const {fn_name} = (args: {params_type}, options?: CallOptions): Promise<{}> =>",
response.ts
)),
}
let call = format!(
"callCommand(\"{}\", {args_payload}, {}, options)",
endpoint.name, response.schema
);
lines.push(match surface {
Surface::Effect => format!(" {call}"),
Surface::Promise => format!(" runPromise({call})"),
});
lines.join("\n")
}
fn emit_channel(endpoint: &Endpoint, graph: &ApiGraph, surface: Surface) -> String {
let fn_name = camel(&endpoint.name);
let item = endpoint
.channel_item
.as_ref()
.map(|ty| type_expr(ty, graph))
.unwrap_or_else(|| TypeExpr::new("unknown", "Schema.Unknown"));
let params_type = params_object(&endpoint.params, graph);
let args_payload = args_payload(&endpoint.params, graph);
let mut lines = doc_block(endpoint.doc.as_deref());
match (surface, params_type.as_str()) {
(Surface::Effect, "void") => lines.push(format!(
"export const {fn_name} = (options?: CallOptions): Stream.Stream<{}, ZynkError, ZynkClient> =>",
item.ts
)),
(Surface::Effect, _) => lines.push(format!(
"export const {fn_name} = (args: {params_type}, options?: CallOptions): Stream.Stream<{}, ZynkError, ZynkClient> =>",
item.ts
)),
(Surface::Promise, "void") => lines.push(format!(
"export const {fn_name} = (options?: CallOptions): AsyncIterable<{}> =>",
item.ts
)),
(Surface::Promise, _) => lines.push(format!(
"export const {fn_name} = (args: {params_type}, options?: CallOptions): AsyncIterable<{}> =>",
item.ts
)),
}
let call = format!(
"callChannel(\"{}\", {args_payload}, {}, options)",
endpoint.name, item.schema
);
lines.push(match surface {
Surface::Effect => format!(" {call}"),
Surface::Promise => format!(" toAsyncIterable({call})"),
});
lines.join("\n")
}
fn emit_upload(endpoint: &Endpoint, graph: &ApiGraph, surface: Surface) -> String {
let fn_name = camel(&endpoint.name);
let response = type_expr(&endpoint.returns, graph);
let params_type = upload_params_object(endpoint, graph);
let files_expr = if endpoint.multi_file {
"args.files"
} else {
"[args.file]"
};
let args_payload = args_payload(&endpoint.params, graph);
let mut lines = doc_block(endpoint.doc.as_deref());
match surface {
Surface::Effect => lines.push(format!(
"export const {fn_name} = (args: {params_type}, options?: UploadOptions): Effect.Effect<{}, ZynkError, ZynkClient> =>",
response.ts
)),
Surface::Promise => lines.push(format!(
"export const {fn_name} = (args: {params_type}, options?: UploadOptions): Promise<{}> =>",
response.ts
)),
}
let call = format!(
"callUpload(\"{}\", {files_expr}, {args_payload}, {}, options)",
endpoint.name, response.schema
);
lines.push(match surface {
Surface::Effect => format!(" {call}"),
Surface::Promise => format!(" runPromise({call})"),
});
lines.join("\n")
}
fn emit_static(endpoint: &Endpoint, graph: &ApiGraph, surface: Surface) -> String {
let fn_name = format!("{}Url", camel(&endpoint.name));
let params_type = params_object(&endpoint.params, graph);
let args_payload = args_payload(&endpoint.params, graph);
let mut lines = doc_block(endpoint.doc.as_deref());
match (surface, params_type.as_str()) {
(Surface::Effect, "void") => lines.push(format!(
"export const {fn_name} = (): Effect.Effect<string, never, ZynkClient> =>"
)),
(Surface::Effect, _) => lines.push(format!(
"export const {fn_name} = (args: {params_type}): Effect.Effect<string, never, ZynkClient> =>"
)),
(Surface::Promise, "void") => lines.push(format!("export const {fn_name} = (): Promise<string> =>")),
(Surface::Promise, _) => lines.push(format!(
"export const {fn_name} = (args: {params_type}): Promise<string> =>"
)),
}
let call = format!("buildStaticUrl(\"{}\", {args_payload})", endpoint.name);
lines.push(match surface {
Surface::Effect => format!(" {call}"),
Surface::Promise => format!(" runPromise({call})"),
});
lines.join("\n")
}
fn emit_websocket(endpoint: &Endpoint, graph: &ApiGraph) -> String {
let pascal = pascal(&endpoint.name);
let server_iface = format!("{pascal}ServerEvents");
let client_iface = format!("{pascal}ClientEvents");
let socket_type = format!("{pascal}Socket");
let factory_name = format!("connect{pascal}");
let mut sections = Vec::new();
sections.push(format!(
"export interface {server_iface} {{\n{}\n}}",
endpoint
.server_events
.iter()
.map(|event| format!(
" {}: {}",
event.source_name,
type_expr(&event.ty, graph).ts
))
.collect::<Vec<_>>()
.join("\n")
));
sections.push(format!(
"export interface {client_iface} {{\n{}\n}}",
endpoint
.client_events
.iter()
.map(|event| format!(
" {}: {}",
event.source_name,
type_expr(&event.ty, graph).ts
))
.collect::<Vec<_>>()
.join("\n")
));
sections.push(format!(
"export interface {socket_type} {{\n readonly socket: WebSocket\n send<K extends keyof {client_iface}>(event: K, data: {client_iface}[K]): Effect.Effect<void, ZynkNetworkError>\n on<K extends keyof {server_iface}>(event: K): Stream.Stream<{server_iface}[K], ZynkNetworkError>\n close: Effect.Effect<void>\n}}"
));
let mut factory = doc_block(endpoint.doc.as_deref());
factory.extend([
format!(
"export const {factory_name} = (): Effect.Effect<{socket_type}, ZynkNetworkError, ZynkClient> =>"
),
" Effect.gen(function* () {".to_string(),
format!(" const ws = yield* openWebSocket(\"{}\")", endpoint.name),
" return {".to_string(),
" socket: ws,".to_string(),
format!(" send<K extends keyof {client_iface}>(event: K, data: {client_iface}[K]) {{"),
" return Effect.try({".to_string(),
" try: () => ws.send(JSON.stringify({ event, data })),".to_string(),
" catch: (cause) =>".to_string(),
" new ZynkNetworkError({ url: ws.url, cause }),".to_string(),
" })".to_string(),
" },".to_string(),
format!(" on<K extends keyof {server_iface}>(event: K) {{"),
format!(" return Stream.async<{server_iface}[K], ZynkNetworkError>((emit) => {{"),
" const listener = (ev: MessageEvent) => {".to_string(),
" try {".to_string(),
" const parsed = JSON.parse(ev.data) as { event: string; data: unknown }".to_string(),
" if (parsed.event === event) {".to_string(),
format!(" void emit.single(parsed.data as {server_iface}[K])"),
" }".to_string(),
" } catch (cause) {".to_string(),
" void emit.fail(new ZynkNetworkError({ url: ws.url, cause }))".to_string(),
" }".to_string(),
" }".to_string(),
" ws.addEventListener(\"message\", listener)".to_string(),
" return Effect.sync(() => ws.removeEventListener(\"message\", listener))".to_string(),
" })".to_string(),
" },".to_string(),
" close: Effect.sync(() => ws.close()),".to_string(),
" }".to_string(),
" })".to_string(),
]);
sections.push(factory.join("\n"));
sections.join("\n\n")
}
fn params_object(params: &[Param], graph: &ApiGraph) -> String {
if params.is_empty() {
return "void".to_string();
}
let required = params
.iter()
.filter(|param| !param_optional(param))
.map(|param| param_field(param, graph))
.collect::<Vec<_>>();
let optional = params
.iter()
.filter(|param| param_optional(param))
.map(|param| param_field(param, graph))
.collect::<Vec<_>>();
format!(
"{{ {} }}",
required
.into_iter()
.chain(optional)
.collect::<Vec<_>>()
.join("; ")
)
}
fn upload_params_object(endpoint: &Endpoint, graph: &ApiGraph) -> String {
let mut fields = vec![if endpoint.multi_file {
"files: ReadonlyArray<File>".to_string()
} else {
"file: File".to_string()
}];
fields.extend(
endpoint
.params
.iter()
.filter(|param| !param_optional(param))
.map(|param| param_field(param, graph)),
);
fields.extend(
endpoint
.params
.iter()
.filter(|param| param_optional(param))
.map(|param| param_field(param, graph)),
);
format!("{{ {} }}", fields.join("; "))
}
fn param_field(param: &Param, graph: &ApiGraph) -> String {
let marker = if param_optional(param) { "?" } else { "" };
let mut ty = param.ty.clone();
ty.optional = false;
let expr = lowering::lower_with_graph(&ty, graph);
format!("{}{marker}: {}", param.wire_name, expr.ts)
}
fn param_optional(param: &Param) -> bool {
!param.required || param.ty.optional
}
fn args_payload(params: &[Param], graph: &ApiGraph) -> String {
if params.is_empty() {
return "{}".to_string();
}
let entries = params
.iter()
.map(|param| {
let value = param_payload_expr(param, graph);
format!("{}: {value}", param.source_name)
})
.collect::<Vec<_>>();
format!("{{ {} }}", entries.join(", "))
}
fn param_payload_expr(param: &Param, graph: &ApiGraph) -> String {
let arg = format!("args.{}", param.wire_name);
if !type_needs_schema_encoding(¶m.ty) {
return arg;
}
let schema = lowering::lower_with_graph(¶m.ty, graph).schema;
if param_optional(param) {
format!("{arg} === undefined ? undefined : Schema.encodeUnknownSync({schema})({arg})")
} else {
format!("Schema.encodeUnknownSync({schema})({arg})")
}
}
fn type_needs_schema_encoding(ty: &TypeRef) -> bool {
match ty.kind {
TypeKind::Model => true,
TypeKind::Array | TypeKind::Tuple | TypeKind::Union | TypeKind::Record => {
ty.inner.iter().any(type_needs_schema_encoding)
}
TypeKind::Primitive
| TypeKind::Literal
| TypeKind::Enum
| TypeKind::Any
| TypeKind::Void => false,
}
}
fn type_expr(ty: &TypeRef, graph: &ApiGraph) -> TypeExpr {
lowering::lower_with_graph(ty, graph)
}
fn doc_block(doc: Option<&str>) -> Vec<String> {
let Some(doc) = doc else {
return Vec::new();
};
let mut out = vec!["/**".to_string()];
for line in doc.trim().lines() {
out.push(format!(" * {}", line.trim()));
}
out.push(" */".to_string());
out
}
fn camel(name: &str) -> String {
zynk_schema::naming::to_camel_case(name)
}
fn pascal(name: &str) -> String {
zynk_schema::naming::to_pascal_case(name)
}