zynk-gen-effect 0.1.2

Effect-TS generator for Zynk API graphs
Documentation
//! Effect-TS generator scaffold for Zynk API graphs.

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;

/// Generate Effect client files with default options.
pub fn generate(graph: &ApiGraph) -> GenerationResult {
    generate_with_options(graph, &EffectGeneratorOptions::default())
}

/// Generate Effect client files with explicit options.
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(),
        ),
    ])
}

/// Concrete generator implementation for the Effect target.
#[derive(Debug, Default, Clone, Copy)]
pub struct EffectGenerator;

impl EffectGenerator {
    /// Create an Effect generator.
    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]
    }
}