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;
25mod dart_visitors;
26pub mod elixir;
27pub mod gleam;
28pub mod go;
29pub mod java;
30pub mod kotlin;
31pub mod kotlin_android;
32pub mod php;
33pub mod python;
34pub mod r;
35pub mod ruby;
36pub mod rust;
37pub mod streaming_assertions;
38pub mod swift;
39mod swift_visitors;
40pub mod typescript;
41pub mod wasm;
42pub mod zig;
43mod zig_visitors;
44
45use crate::config::E2eConfig;
46use crate::fixture::{Fixture, FixtureGroup};
47use alef_core::backend::GeneratedFile;
48use alef_core::config::ResolvedCrateConfig;
49use alef_core::ir::TypeDef;
50use anyhow::Result;
51
52/// Check if a fixture should be included for the given language.
53///
54/// Returns false if:
55/// - The fixture's resolved category is in `e2e_config.exclude_categories`
56/// (fixture is excluded from every language's cross-language e2e codegen)
57/// - The fixture has a skip condition that applies to this language
58/// - The fixture's call has no resolvable function for this language (no base
59/// `function` set and no override for the language). Calls that share a base
60/// function but only carry per-language type/arg overrides are still emitted
61/// for languages without an explicit override.
62pub(crate) fn should_include_fixture(fixture: &Fixture, language: &str, e2e_config: &E2eConfig) -> bool {
63 if !e2e_config.exclude_categories.is_empty() && e2e_config.exclude_categories.contains(&fixture.resolved_category())
64 {
65 return false;
66 }
67 if let Some(skip) = &fixture.skip {
68 if skip.should_skip(language) {
69 return false;
70 }
71 }
72 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
73 // Also respect skip_languages on the resolved call (e.g. batch_scrape skips elixir).
74 if call_config.skip_languages.iter().any(|l| l == language) {
75 return false;
76 }
77 if call_config.function.is_empty() && !call_config.overrides.contains_key(language) {
78 return false;
79 }
80 true
81}
82
83/// Recursively rewrite a JSON value's object keys to the target wire case.
84///
85/// `wire_case` accepts the same vocabulary as serde's `rename_all` attribute:
86/// `"snake_case"` (default), `"camelCase"`, `"PascalCase"`, `"SCREAMING_SNAKE_CASE"`,
87/// `"kebab-case"`, `"SCREAMING-KEBAB-CASE"`. Unknown values fall back to `snake_case`.
88///
89/// Used by per-language e2e codegen to translate canonical (snake_case) fixture keys
90/// to the wire case that each binding's `from_json` / typed deserializer expects, as
91/// driven by `ResolvedCrateConfig::serde_rename_all_for_language`.
92pub(crate) fn transform_json_keys_for_language(value: &serde_json::Value, wire_case: &str) -> serde_json::Value {
93 use heck::{ToKebabCase, ToLowerCamelCase, ToPascalCase, ToShoutyKebabCase, ToShoutySnakeCase, ToSnakeCase};
94 let rewrite_key: fn(&str) -> String = match wire_case {
95 "camelCase" => |k| k.to_lower_camel_case(),
96 "PascalCase" => |k| k.to_pascal_case(),
97 "SCREAMING_SNAKE_CASE" => |k| k.to_shouty_snake_case(),
98 "kebab-case" => |k| k.to_kebab_case(),
99 "SCREAMING-KEBAB-CASE" => |k| k.to_shouty_kebab_case(),
100 _ => |k| k.to_snake_case(),
101 };
102 fn walk(value: &serde_json::Value, rewrite_key: fn(&str) -> String) -> serde_json::Value {
103 match value {
104 serde_json::Value::Object(obj) => {
105 let new_obj: serde_json::Map<String, serde_json::Value> = obj
106 .iter()
107 .map(|(k, v)| (rewrite_key(k), walk(v, rewrite_key)))
108 .collect();
109 serde_json::Value::Object(new_obj)
110 }
111 serde_json::Value::Array(arr) => {
112 serde_json::Value::Array(arr.iter().map(|v| walk(v, rewrite_key)).collect())
113 }
114 other => other.clone(),
115 }
116 }
117 walk(value, rewrite_key)
118}
119
120/// Trait for per-language e2e test code generation.
121pub trait E2eCodegen: Send + Sync {
122 /// Generate all e2e test project files for this language.
123 ///
124 /// `type_defs` is the IR type registry extracted from the source crate.
125 /// It is used by backends that need to introspect struct field types at
126 /// codegen time (e.g. the TypeScript/WASM generator uses it to
127 /// auto-derive `nested_types` mappings for wasm-bindgen class wrapping).
128 ///
129 /// `enums` is the IR enum registry extracted from the source crate.
130 /// For WASM, it is used to identify tagged-data enums so they are emitted
131 /// as plain JS object literals instead of wrapper factories.
132 fn generate(
133 &self,
134 groups: &[FixtureGroup],
135 e2e_config: &E2eConfig,
136 config: &ResolvedCrateConfig,
137 type_defs: &[TypeDef],
138 enums: &[alef_core::ir::EnumDef],
139 ) -> Result<Vec<GeneratedFile>>;
140
141 /// Language name for display and directory naming.
142 fn language_name(&self) -> &'static str;
143}
144
145/// Get all available e2e code generators.
146pub fn all_generators() -> Vec<Box<dyn E2eCodegen>> {
147 vec![
148 Box::new(rust::RustE2eCodegen),
149 Box::new(python::PythonE2eCodegen),
150 Box::new(typescript::TypeScriptCodegen),
151 Box::new(go::GoCodegen),
152 Box::new(java::JavaCodegen),
153 Box::new(kotlin::KotlinE2eCodegen),
154 Box::new(kotlin_android::KotlinAndroidE2eCodegen),
155 Box::new(csharp::CSharpCodegen),
156 Box::new(php::PhpCodegen),
157 Box::new(ruby::RubyCodegen),
158 Box::new(elixir::ElixirCodegen),
159 Box::new(gleam::GleamE2eCodegen),
160 Box::new(r::RCodegen),
161 Box::new(wasm::WasmCodegen),
162 Box::new(c::CCodegen),
163 Box::new(zig::ZigE2eCodegen),
164 Box::new(dart::DartE2eCodegen),
165 Box::new(swift::SwiftE2eCodegen),
166 Box::new(brew::BrewCodegen),
167 ]
168}
169
170/// Get e2e code generators for specific language names.
171pub fn generators_for(languages: &[String]) -> Vec<Box<dyn E2eCodegen>> {
172 all_generators()
173 .into_iter()
174 .filter(|g| languages.iter().any(|l| l == g.language_name()))
175 .collect()
176}
177
178/// Resolve a JSON field from a fixture input by path.
179///
180/// Field paths in call config are "input.path", "input.config", etc.
181/// Since we already receive `fixture.input`, strip the leading "input." prefix.
182/// When `field_path` is exactly `"input"`, the whole input object is returned.
183pub(crate) fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
184 // "input" with no subpath means "the entire input object".
185 if field_path == "input" {
186 return input;
187 }
188 let path = field_path.strip_prefix("input.").unwrap_or(field_path);
189 let mut current = input;
190 for part in path.split('.') {
191 current = current.get(part).unwrap_or(&serde_json::Value::Null);
192 }
193 current
194}