alef_e2e/codegen/client/mod.rs
1//! Shared HTTP/WebSocket test-codegen abstractions.
2//!
3//! Per-language e2e codegen (`crates/alef-e2e/src/codegen/<lang>.rs`) was previously
4//! a monolithic ~1k-2k-line file per language that duplicated the structural shape
5//! of every test (function header, request build, response assert) and only
6//! differed in syntax. This module pulls the common shape into a trait + driver
7//! pair so each language file becomes thin: implement primitives, delegate to
8//! [`http_call::render_http_test`] / [`ws_script::render_ws_test`].
9//!
10//! The trait targets the **TestClient-driven** test shape — i.e. tests call
11//! `client.METHOD(path, body, headers)` against a `TestClient` exposed by the
12//! language binding, rather than spinning up a TCP mock server. Languages that
13//! cannot expose `TestClient` over FFI (Go/Java/C#) implement the same trait but
14//! emit code that spawns the binding's `App.serve()` on a loopback port and
15//! drives it with their stdlib HTTP client.
16
17use crate::fixture::{Fixture, HttpExpectedResponse, HttpRequest, ValidationErrorExpectation};
18use std::collections::HashMap;
19
20pub mod http_call;
21pub mod ws_script;
22
23/// Context for rendering a single TestClient HTTP call.
24///
25/// `response_var` is the binding-side identifier the renderer should use when
26/// emitting subsequent assertions (e.g. `response`, `_resp`, `let response =`).
27#[derive(Debug)]
28pub struct CallCtx<'a> {
29 pub method: &'a str,
30 pub path: &'a str,
31 pub headers: &'a HashMap<String, String>,
32 pub query_params: &'a HashMap<String, serde_json::Value>,
33 pub cookies: &'a HashMap<String, String>,
34 pub body: Option<&'a serde_json::Value>,
35 pub content_type: Option<&'a str>,
36 pub response_var: &'a str,
37}
38
39impl<'a> CallCtx<'a> {
40 pub fn from_request(req: &'a HttpRequest, response_var: &'a str) -> Self {
41 Self {
42 method: req.method.as_str(),
43 path: req.path.as_str(),
44 headers: &req.headers,
45 query_params: &req.query_params,
46 cookies: &req.cookies,
47 body: req.body.as_ref(),
48 content_type: req.content_type.as_deref(),
49 response_var,
50 }
51 }
52}
53
54/// Per-language TestClient test renderer.
55///
56/// Implementations live alongside the per-language codegen module
57/// (`crates/alef-e2e/src/codegen/<lang>/client.rs`). The shared driver
58/// [`http_call::render_http_test`] calls these primitives in order to assemble
59/// a complete test. Methods append to `out`; they MUST NOT clear or seek it.
60///
61/// Most methods take a `response_var: &str` argument so the renderer can
62/// reference the value bound by `render_call`. Default value: `"response"`.
63pub trait TestClientRenderer {
64 /// Identifier used in fixture skip directives (e.g. `"python"`, `"node"`).
65 fn language_name(&self) -> &'static str;
66
67 /// Convert a fixture id (`my_test_id`) to a language-valid identifier.
68 /// Default implementation lower-cases and replaces non-alphanumerics with `_`.
69 fn sanitize_test_name(&self, id: &str) -> String {
70 crate::escape::sanitize_ident(id)
71 }
72
73 /// Render the test-function opening: doc, signature, opening brace.
74 /// `skip_reason: Some(...)` means the fixture is skipped for this language;
75 /// the renderer should emit the language-native skip annotation.
76 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>);
77
78 /// Render the test-function closing brace / `end` / etc.
79 fn render_test_close(&self, out: &mut String);
80
81 /// Render `let <response_var> = client.METHOD(path, body, query, headers, cookies)`
82 /// (or per-language equivalent). Including the trailing newline.
83 fn render_call(&self, out: &mut String, ctx: &CallCtx<'_>);
84
85 /// Render `assert <response_var>.status == status`.
86 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16);
87
88 /// Render `assert <response_var>.headers[name] == expected`.
89 /// `expected` may be a literal value or one of the special tokens `<<uuid>>`,
90 /// `<<present>>`, `<<absent>>` per the fixture schema; the renderer is
91 /// responsible for handling those.
92 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str);
93
94 /// Render an exact-equality JSON body assertion. The renderer is responsible
95 /// for parsing the response body as JSON (or the appropriate language-native
96 /// equivalent) and comparing it to `expected`.
97 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value);
98
99 /// Render a partial JSON body assertion: every field in `expected` must be
100 /// present in the response with the same value, but the response may have
101 /// additional fields.
102 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value);
103
104 /// Render a validation-errors assertion for 422 responses.
105 fn render_assert_validation_errors(
106 &self,
107 out: &mut String,
108 response_var: &str,
109 errors: &[ValidationErrorExpectation],
110 );
111}
112
113/// Whether a fixture is skipped for the given language.
114///
115/// Pulled into the shared driver layer so individual renderers don't reimplement.
116pub fn is_skipped(fixture: &Fixture, language: &str) -> bool {
117 fixture
118 .skip
119 .as_ref()
120 .map(|s| s.languages.iter().any(|l| l == language))
121 .unwrap_or(false)
122}
123
124/// Whether the expected-response carries any header expectations beyond
125/// content-encoding (which the mock layer strips and is therefore not asserted).
126pub fn has_meaningful_headers(expected: &HttpExpectedResponse) -> bool {
127 expected
128 .headers
129 .iter()
130 .any(|(k, _)| !k.eq_ignore_ascii_case("content-encoding"))
131}
132
133/// Whether the expected-response carries a non-empty body.
134///
135/// The fixture schema uses `null` and `""` as "no body" sentinels — neither
136/// triggers a body assertion.
137pub fn has_meaningful_body(expected: &HttpExpectedResponse) -> bool {
138 match &expected.body {
139 Some(v) if v.is_null() => false,
140 Some(v) if v.as_str() == Some("") => false,
141 Some(_) => true,
142 None => false,
143 }
144}