pub mod lowering;
pub mod options;
pub mod printer;
pub mod runtime;
pub mod topological;
pub mod types;
use std::path::PathBuf;
use options::EffectGeneratorOptions;
use zynk_codegen::{GeneratedFile, GenerationContext, GenerationResult, Generator};
use zynk_schema::ApiGraph;
pub fn generate(graph: &ApiGraph) -> GenerationResult {
generate_with_options(graph, &EffectGeneratorOptions::default())
}
pub fn generate_with_options(
graph: &ApiGraph,
options: &EffectGeneratorOptions,
) -> GenerationResult {
let api = printer::print_api(graph, options);
GenerationResult::new(vec![
GeneratedFile::new(PathBuf::from("api.ts"), api),
GeneratedFile::new(
PathBuf::from("_effect_internal.ts"),
runtime::effect_internal_ts(),
),
])
}
#[derive(Debug, Default, Clone, Copy)]
pub struct EffectGenerator;
impl EffectGenerator {
pub fn new() -> Self {
Self
}
}
impl Generator for EffectGenerator {
fn generate(&self, ctx: &GenerationContext) -> GenerationResult {
let options = EffectGeneratorOptions::from_mapping(ctx.options).unwrap_or_default();
generate_with_options(ctx.graph, &options)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use zynk_schema::{ApiGraph, Endpoint, EndpointKind, Field, ModelDef, Param, TypeRef};
use crate::options::{EffectGeneratorOptions, Surface};
#[test]
fn generate_returns_api_and_embedded_effect_runtime_files() {
let result = super::generate(&ApiGraph::new());
assert_eq!(result.files.len(), 2);
assert_eq!(result.files[0].path, PathBuf::from("api.ts"));
assert_eq!(result.files[1].path, PathBuf::from("_effect_internal.ts"));
assert!(result.files[0]
.contents
.contains("Auto-generated by Zynk Effect connector"));
assert!(result.files[1]
.contents
.contains("export const callCommand"));
}
#[test]
fn scaffold_api_includes_lowered_schema_structs() {
let mut graph = ApiGraph::new();
let mut user = ModelDef::new("User");
user.fields.push(Field::new(
"name",
"name",
TypeRef::primitive("string"),
true,
));
graph.insert_model(user);
let result = super::generate(&graph);
assert!(result.files[0]
.contents
.contains("export const User = Schema.Struct"));
assert!(result.files[0].contents.contains("name: Schema.String"));
}
#[test]
fn conditional_imports_follow_endpoint_kinds_and_surfaces() {
let commands_only = graph_with(vec![Endpoint::new(
"ping",
EndpointKind::Rpc,
TypeRef::primitive("string"),
)]);
let effect_api = super::generate(&commands_only).files[0].contents.clone();
assert!(effect_api.contains("import {\n Effect,\n Schema,\n} from \"effect\""));
assert!(!effect_api.contains("Stream,"));
assert!(effect_api.contains(" callCommand,"));
assert!(!effect_api.contains("runPromise,"));
assert!(!effect_api.contains("callChannel,"));
assert!(!effect_api.contains("toAsyncIterable,"));
let promise_options = EffectGeneratorOptions {
commands: Some(Surface::Promise),
..EffectGeneratorOptions::default()
};
let promise_api = super::generate_with_options(&commands_only, &promise_options).files[0]
.contents
.clone();
assert!(promise_api.contains(" runPromise,"));
assert!(
promise_api.contains("export const ping = (options?: CallOptions): Promise<string>")
);
assert!(
promise_api.contains("runPromise(callCommand(\"ping\", {}, Schema.String, options))")
);
let channel_effect = graph_with(vec![channel_endpoint()]);
let channel_effect_api = super::generate(&channel_effect).files[0].contents.clone();
assert!(channel_effect_api.contains(" Stream,"));
assert!(channel_effect_api.contains(" callChannel,"));
assert!(channel_effect_api.contains(" toAsyncIterable,"));
assert!(
!channel_effect_api.contains("import {\n callCommand,\n callChannel,\n runPromise,")
);
assert!(!internal_import_block(&channel_effect_api).contains("toAsyncIterable,"));
assert!(public_export_block(&channel_effect_api).contains("toAsyncIterable,"));
let channel_promise_options = EffectGeneratorOptions {
channels: Some(Surface::Promise),
..EffectGeneratorOptions::default()
};
let channel_promise_api =
super::generate_with_options(&channel_effect, &channel_promise_options).files[0]
.contents
.clone();
assert!(internal_import_block(&channel_promise_api).contains("toAsyncIterable,"));
assert!(channel_promise_api.contains("AsyncIterable<number>"));
assert!(channel_promise_api.contains("toAsyncIterable(callChannel(\"stream_numbers\""));
let mut every_kind = vec![channel_endpoint(), upload_endpoint(), static_endpoint()];
every_kind.push(websocket_endpoint());
let api = super::generate(&graph_with(every_kind)).files[0]
.contents
.clone();
let imports = internal_import_block(&api);
assert!(imports.contains("callUpload,"));
assert!(imports.contains("buildStaticUrl,"));
assert!(imports.contains("openWebSocket,"));
assert!(imports.contains("type UploadOptions,"));
assert!(imports.contains("ZynkNetworkError,"));
assert!(public_export_block(&api).contains("type UploadProgressEvent,"));
}
#[test]
fn emits_per_kind_surface_dispatch_docs_and_names() {
let mut rpc = Endpoint::new(
"get_user_profile",
EndpointKind::Rpc,
TypeRef::model("user"),
);
rpc.doc = Some(" Fetch a user. \n Keeps doc comments. ".to_string());
rpc.params.push(Param::new(
"user_id",
"userId",
TypeRef::primitive("number"),
true,
));
let mut user = ModelDef::new("user");
user.fields.push(Field::new(
"full_name",
"fullName",
TypeRef::primitive("string"),
true,
));
let options = EffectGeneratorOptions {
commands: Some(Surface::Promise),
uploads: Some(Surface::Promise),
statics: Some(Surface::Promise),
channels: Some(Surface::Promise),
websockets: Some(Surface::Promise),
..EffectGeneratorOptions::default()
};
let mut graph = graph_with(vec![
rpc,
channel_endpoint(),
upload_endpoint(),
static_endpoint(),
websocket_endpoint(),
]);
graph.insert_model(user);
let api = super::generate_with_options(&graph, &options).files[0]
.contents
.clone();
assert!(api.contains(
"/**\n * Fetch a user.\n * Keeps doc comments.\n */\nexport const getUserProfile"
));
assert!(api.contains(
"getUserProfile = (args: { userId: number }, options?: CallOptions): Promise<User> =>"
));
assert!(api.contains(
"callCommand(\"get_user_profile\", { user_id: args.userId }, User, options)"
));
assert!(api.contains("streamNumbers = (options?: CallOptions): AsyncIterable<number> =>"));
assert!(api.contains("uploadAsset = (args: { file: File; label?: string }, options?: UploadOptions): Promise<string> =>"));
assert!(api.contains("runPromise(callUpload(\"upload_asset\", [args.file], { label: args.label }, Schema.String, options))"));
assert!(api.contains("assetUrl = (args: { assetId: string }): Promise<string> =>"));
assert!(api.contains("runPromise(buildStaticUrl(\"asset\", { asset_id: args.assetId }))"));
assert!(api.contains("export interface ChatRoomServerEvents"));
assert!(api.contains(" new_message: string"));
assert!(api.contains("export interface ChatRoomClientEvents"));
assert!(api.contains(" join_room: string"));
assert!(api.contains("export interface ChatRoomSocket"));
assert!(api.contains("export const connectChatRoom = (): Effect.Effect<ChatRoomSocket, ZynkNetworkError, ZynkClient> =>"));
assert!(api.contains("openWebSocket(\"chat_room\")"));
}
fn graph_with(endpoints: Vec<Endpoint>) -> ApiGraph {
let mut graph = ApiGraph::new();
for endpoint in endpoints {
graph.insert_endpoint(endpoint);
}
graph
}
fn channel_endpoint() -> Endpoint {
let mut endpoint = Endpoint::new("stream_numbers", EndpointKind::Channel, TypeRef::void());
endpoint.channel_item = Some(TypeRef::primitive("number"));
endpoint
}
fn upload_endpoint() -> Endpoint {
let mut endpoint = Endpoint::new(
"upload_asset",
EndpointKind::Upload,
TypeRef::primitive("string"),
);
endpoint.params.push(Param::new(
"label",
"label",
TypeRef::primitive("string"),
false,
));
endpoint
}
fn static_endpoint() -> Endpoint {
let mut endpoint = Endpoint::new("asset", EndpointKind::Static, TypeRef::void());
endpoint.params.push(Param::new(
"asset_id",
"assetId",
TypeRef::primitive("string"),
true,
));
endpoint
}
fn websocket_endpoint() -> Endpoint {
let mut endpoint = Endpoint::new("chat_room", EndpointKind::Ws, TypeRef::void());
endpoint.server_events.push(Param::new(
"new_message",
"newMessage",
TypeRef::primitive("string"),
true,
));
endpoint.client_events.push(Param::new(
"join_room",
"joinRoom",
TypeRef::primitive("string"),
true,
));
endpoint
}
fn internal_import_block(api: &str) -> &str {
let start = api
.find("import {\n callCommand,")
.expect("internal import starts");
let end = api[start..]
.find("} from \"./_effect_internal\"")
.expect("internal import ends");
&api[start..start + end]
}
fn public_export_block(api: &str) -> &str {
let start = api
.find("export {\n ZynkClient,")
.expect("public export starts");
let end = api[start..]
.find("} from \"./_effect_internal\"")
.expect("public export ends");
&api[start..start + end]
}
}