alef_e2e/codegen/mod.rs
1//! E2e test code generation trait and language dispatch.
2//!
3//! ## DRY layer ([`client`])
4//!
5//! Per-language e2e codegen historically duplicated the structural shape of every
6//! test (function header, request build, response assert) and only differed in
7//! syntax. The [`client`] submodule pulls that shape into trait + driver pairs
8//! ([`client::TestClientRenderer`] + [`client::http_call::render_http_test`])
9//! so each language can be migrated to TestClient-driven tests by:
10//!
11//! 1. Implementing `TestClientRenderer` once per language (small, mechanical).
12//! 2. Replacing the language's monolithic `render_http_test_function` with a
13//! call to `client::http_call::render_http_test(out, &MyRenderer, fixture)`.
14//! 3. Optionally splitting the per-language file into a directory
15//! `<lang>/{mod.rs,client.rs,ws.rs,helpers.rs}` when the file gets unwieldy.
16//!
17//! Until a language migrates, it continues using the legacy monolithic renderer —
18//! both can coexist behind the per-language [`E2eCodegen::generate`] entry.
19
20pub mod brew;
21pub mod c;
22pub mod client;
23pub mod csharp;
24pub mod dart;
25pub mod elixir;
26pub mod gleam;
27pub mod go;
28pub mod java;
29pub mod kotlin;
30pub mod php;
31pub mod python;
32pub mod r;
33pub mod ruby;
34pub mod rust;
35pub mod swift;
36pub mod typescript;
37pub mod wasm;
38pub mod zig;
39
40use crate::config::E2eConfig;
41use crate::fixture::{Fixture, FixtureGroup};
42use alef_core::backend::GeneratedFile;
43use alef_core::config::ResolvedCrateConfig;
44use alef_core::ir::TypeDef;
45use anyhow::Result;
46
47/// Check if a fixture should be included for the given language.
48///
49/// Returns false if:
50/// - The fixture's resolved category is in `e2e_config.exclude_categories`
51/// (fixture is excluded from every language's cross-language e2e codegen)
52/// - The fixture has a skip condition that applies to this language
53/// - The fixture's call has no resolvable function for this language (no base
54/// `function` set and no override for the language). Calls that share a base
55/// function but only carry per-language type/arg overrides are still emitted
56/// for languages without an explicit override.
57pub(crate) fn should_include_fixture(fixture: &Fixture, language: &str, e2e_config: &E2eConfig) -> bool {
58 if !e2e_config.exclude_categories.is_empty() && e2e_config.exclude_categories.contains(&fixture.resolved_category())
59 {
60 return false;
61 }
62 if let Some(skip) = &fixture.skip {
63 if skip.should_skip(language) {
64 return false;
65 }
66 }
67 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
68 // Also respect skip_languages on the resolved call (e.g. batch_scrape skips elixir).
69 if call_config.skip_languages.iter().any(|l| l == language) {
70 return false;
71 }
72 if call_config.function.is_empty() && !call_config.overrides.contains_key(language) {
73 return false;
74 }
75 true
76}
77
78/// Convert a JSON value's object keys from camelCase to snake_case recursively.
79///
80/// Used when serializing fixture options for FFI-based languages (Rust, C, Java)
81/// where the receiving Rust type uses default serde (snake_case) without `rename_all`.
82pub(crate) fn normalize_json_keys_to_snake_case(value: &serde_json::Value) -> serde_json::Value {
83 use heck::ToSnakeCase;
84 match value {
85 serde_json::Value::Object(obj) => {
86 let new_obj: serde_json::Map<String, serde_json::Value> = obj
87 .iter()
88 .map(|(k, v)| (k.to_snake_case(), normalize_json_keys_to_snake_case(v)))
89 .collect();
90 serde_json::Value::Object(new_obj)
91 }
92 serde_json::Value::Array(arr) => {
93 serde_json::Value::Array(arr.iter().map(normalize_json_keys_to_snake_case).collect())
94 }
95 other => other.clone(),
96 }
97}
98
99/// Trait for per-language e2e test code generation.
100pub trait E2eCodegen: Send + Sync {
101 /// Generate all e2e test project files for this language.
102 ///
103 /// `type_defs` is the IR type registry extracted from the source crate.
104 /// It is used by backends that need to introspect struct field types at
105 /// codegen time (e.g. the TypeScript/WASM generator uses it to
106 /// auto-derive `nested_types` mappings for wasm-bindgen class wrapping).
107 fn generate(
108 &self,
109 groups: &[FixtureGroup],
110 e2e_config: &E2eConfig,
111 config: &ResolvedCrateConfig,
112 type_defs: &[TypeDef],
113 ) -> Result<Vec<GeneratedFile>>;
114
115 /// Language name for display and directory naming.
116 fn language_name(&self) -> &'static str;
117}
118
119/// Get all available e2e code generators.
120pub fn all_generators() -> Vec<Box<dyn E2eCodegen>> {
121 vec![
122 Box::new(rust::RustE2eCodegen),
123 Box::new(python::PythonE2eCodegen),
124 Box::new(typescript::TypeScriptCodegen),
125 Box::new(go::GoCodegen),
126 Box::new(java::JavaCodegen),
127 Box::new(kotlin::KotlinE2eCodegen),
128 Box::new(csharp::CSharpCodegen),
129 Box::new(php::PhpCodegen),
130 Box::new(ruby::RubyCodegen),
131 Box::new(elixir::ElixirCodegen),
132 Box::new(gleam::GleamE2eCodegen),
133 Box::new(r::RCodegen),
134 Box::new(wasm::WasmCodegen),
135 Box::new(c::CCodegen),
136 Box::new(zig::ZigE2eCodegen),
137 Box::new(dart::DartE2eCodegen),
138 Box::new(swift::SwiftE2eCodegen),
139 Box::new(brew::BrewCodegen),
140 ]
141}
142
143/// Get e2e code generators for specific language names.
144pub fn generators_for(languages: &[String]) -> Vec<Box<dyn E2eCodegen>> {
145 all_generators()
146 .into_iter()
147 .filter(|g| languages.iter().any(|l| l == g.language_name()))
148 .collect()
149}
150
151/// Resolve a JSON field from a fixture input by path.
152///
153/// Field paths in call config are "input.path", "input.config", etc.
154/// Since we already receive `fixture.input`, strip the leading "input." prefix.
155/// When `field_path` is exactly `"input"`, the whole input object is returned.
156pub(crate) fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
157 // "input" with no subpath means "the entire input object".
158 if field_path == "input" {
159 return input;
160 }
161 let path = field_path.strip_prefix("input.").unwrap_or(field_path);
162 let mut current = input;
163 for part in path.split('.') {
164 current = current.get(part).unwrap_or(&serde_json::Value::Null);
165 }
166 current
167}