Skip to main content

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}