Skip to main content

alef_e2e/codegen/rust/
mock_server.rs

1//! Mock server source generation for Rust e2e tests.
2
3use std::fmt::Write as FmtWrite;
4
5use alef_core::hash::{self, CommentStyle};
6
7use crate::config::E2eConfig;
8use crate::escape::rust_raw_string;
9use crate::fixture::Fixture;
10
11/// Emit mock server setup lines into a test function body.
12///
13/// Builds `MockRoute` objects from the fixture's `mock_response` and starts
14/// the server.  The resulting `mock_server` variable is in scope for the rest
15/// of the test function.
16pub fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
17    let mock = match fixture.mock_response.as_ref() {
18        Some(m) => m,
19        None => return,
20    };
21
22    // Resolve the HTTP path and method from the call config.
23    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
24    let path = call_config.path.as_deref().unwrap_or("/");
25    let method = call_config.method.as_deref().unwrap_or("POST");
26
27    let status = mock.status;
28
29    // Render headers map as a Vec<(String, String)> literal for stable iteration order.
30    let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
31    header_entries.sort_by(|a, b| a.0.cmp(b.0));
32    let render_headers = |out: &mut String| {
33        let _ = writeln!(out, "        headers: vec![");
34        for (name, value) in &header_entries {
35            let n = rust_raw_string(name);
36            let v = rust_raw_string(value);
37            let _ = writeln!(out, "            ({n}.to_string(), {v}.to_string()),");
38        }
39        let _ = writeln!(out, "        ],");
40    };
41
42    if let Some(chunks) = &mock.stream_chunks {
43        // Streaming SSE response.
44        let _ = writeln!(out, "    let mock_route = MockRoute {{");
45        let _ = writeln!(out, "        path: \"{path}\",");
46        let _ = writeln!(out, "        method: \"{method}\",");
47        let _ = writeln!(out, "        status: {status},");
48        let _ = writeln!(out, "        body: String::new(),");
49        let _ = writeln!(out, "        stream_chunks: vec![");
50        for chunk in chunks {
51            let chunk_str = match chunk {
52                serde_json::Value::String(s) => rust_raw_string(s),
53                other => {
54                    let s = serde_json::to_string(other).unwrap_or_default();
55                    rust_raw_string(&s)
56                }
57            };
58            let _ = writeln!(out, "            {chunk_str}.to_string(),");
59        }
60        let _ = writeln!(out, "        ],");
61        render_headers(out);
62        let _ = writeln!(out, "    }};");
63    } else {
64        // Non-streaming JSON response.
65        let body_str = match &mock.body {
66            Some(b) => {
67                let s = serde_json::to_string(b).unwrap_or_default();
68                rust_raw_string(&s)
69            }
70            None => rust_raw_string("{}"),
71        };
72        let _ = writeln!(out, "    let mock_route = MockRoute {{");
73        let _ = writeln!(out, "        path: \"{path}\",");
74        let _ = writeln!(out, "        method: \"{method}\",");
75        let _ = writeln!(out, "        status: {status},");
76        let _ = writeln!(out, "        body: {body_str}.to_string(),");
77        let _ = writeln!(out, "        stream_chunks: vec![],");
78        render_headers(out);
79        let _ = writeln!(out, "    }};");
80    }
81
82    let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
83}
84
85/// Generate the complete `mock_server.rs` module source.
86pub fn render_mock_server_module() -> String {
87    // This is parameterized Axum mock server code identical in structure to
88    // liter-llm's mock_server.rs but without any project-specific imports.
89    hash::header(CommentStyle::DoubleSlash)
90        + r#"//
91// Minimal axum-based mock HTTP server for e2e tests.
92
93use std::net::SocketAddr;
94use std::sync::Arc;
95
96use axum::Router;
97use axum::body::Body;
98use axum::extract::State;
99use axum::http::{Request, StatusCode};
100use axum::response::{IntoResponse, Response};
101use tokio::net::TcpListener;
102
103/// A single mock route: match by path + method, return a configured response.
104#[derive(Clone, Debug)]
105pub struct MockRoute {
106    /// URL path to match, e.g. `"/v1/chat/completions"`.
107    pub path: &'static str,
108    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
109    pub method: &'static str,
110    /// HTTP status code to return.
111    pub status: u16,
112    /// Response body JSON string (used when `stream_chunks` is empty).
113    pub body: String,
114    /// Ordered SSE data payloads for streaming responses.
115    /// Each entry becomes `data: <chunk>\n\n` in the response.
116    /// A final `data: [DONE]\n\n` is always appended.
117    pub stream_chunks: Vec<String>,
118    /// Response headers to apply (name, value) pairs.
119    /// Multiple entries with the same name produce multiple header lines.
120    pub headers: Vec<(String, String)>,
121}
122
123struct ServerState {
124    routes: Vec<MockRoute>,
125}
126
127pub struct MockServer {
128    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
129    pub url: String,
130    handle: tokio::task::JoinHandle<()>,
131}
132
133impl MockServer {
134    /// Start a mock server with the given routes.  Binds to a random port on
135    /// localhost and returns immediately once the server is listening.
136    pub async fn start(routes: Vec<MockRoute>) -> Self {
137        let state = Arc::new(ServerState { routes });
138
139        let app = Router::new().fallback(handle_request).with_state(state);
140
141        let listener = TcpListener::bind("127.0.0.1:0")
142            .await
143            .expect("Failed to bind mock server port");
144        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
145        let url = format!("http://{addr}");
146
147        let handle = tokio::spawn(async move {
148            axum::serve(listener, app).await.expect("Mock server failed");
149        });
150
151        MockServer { url, handle }
152    }
153
154    /// Stop the mock server.
155    pub fn shutdown(self) {
156        self.handle.abort();
157    }
158}
159
160impl Drop for MockServer {
161    fn drop(&mut self) {
162        self.handle.abort();
163    }
164}
165
166async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
167    let path = req.uri().path().to_owned();
168    let method = req.method().as_str().to_uppercase();
169
170    for route in &state.routes {
171        // Match on method and either exact path or path prefix (route.path is a prefix of the
172        // request path, separated by a '/' boundary). This allows a single route registered at
173        // "/v1/batches" to match requests to "/v1/batches/abc123" or
174        // "/v1/batches/abc123/cancel".
175        let path_matches = path == route.path
176            || (path.starts_with(route.path)
177                && path.as_bytes().get(route.path.len()) == Some(&b'/'));
178        if path_matches && route.method.to_uppercase() == method {
179            let status =
180                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
181
182            if !route.stream_chunks.is_empty() {
183                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
184                let mut sse = String::new();
185                for chunk in &route.stream_chunks {
186                    sse.push_str("data: ");
187                    sse.push_str(chunk);
188                    sse.push_str("\n\n");
189                }
190                sse.push_str("data: [DONE]\n\n");
191
192                let mut builder = Response::builder()
193                    .status(status)
194                    .header("content-type", "text/event-stream")
195                    .header("cache-control", "no-cache");
196                for (name, value) in &route.headers {
197                    builder = builder.header(name, value);
198                }
199                return builder.body(Body::from(sse)).unwrap().into_response();
200            }
201
202            let mut builder =
203                Response::builder().status(status).header("content-type", "application/json");
204            for (name, value) in &route.headers {
205                builder = builder.header(name, value);
206            }
207            return builder.body(Body::from(route.body.clone())).unwrap().into_response();
208        }
209    }
210
211    // No matching route → 404.
212    Response::builder()
213        .status(StatusCode::NOT_FOUND)
214        .body(Body::from(format!("No mock route for {method} {path}")))
215        .unwrap()
216        .into_response()
217}
218"#
219}
220
221/// Generate the `src/main.rs` for the standalone mock server binary.
222///
223/// The binary:
224/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
225/// - For each fixture that has a `mock_response` field, registers a route at
226///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
227/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
228///   to stdout, then waits until stdin is closed for clean teardown.
229///
230/// This binary is intended for cross-language e2e suites (WASM, Node) that
231/// spawn it as a child process and read the URL from its stdout.
232pub fn render_mock_server_binary() -> String {
233    hash::header(CommentStyle::DoubleSlash)
234        + r#"//
235// Standalone mock HTTP server binary for cross-language e2e tests.
236// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
237//
238// Usage: mock-server [fixtures-dir]
239//   fixtures-dir defaults to "../../fixtures"
240//
241// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
242// then blocks until stdin is closed (parent process exit triggers cleanup).
243
244use std::collections::HashMap;
245use std::io::{self, BufRead};
246use std::net::SocketAddr;
247use std::path::Path;
248use std::sync::Arc;
249
250use axum::Router;
251use axum::body::Body;
252use axum::extract::State;
253use axum::http::{Request, StatusCode};
254use axum::response::{IntoResponse, Response};
255use serde::Deserialize;
256use tokio::net::TcpListener;
257
258// ---------------------------------------------------------------------------
259// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
260// Supports both schemas:
261//   liter-llm: mock_response: { status, body, stream_chunks }
262//   spikard:   http.expected_response: { status_code, body, headers }
263// ---------------------------------------------------------------------------
264
265#[derive(Debug, Deserialize)]
266struct MockResponse {
267    status: u16,
268    #[serde(default)]
269    body: Option<serde_json::Value>,
270    #[serde(default)]
271    stream_chunks: Option<Vec<serde_json::Value>>,
272    #[serde(default)]
273    headers: HashMap<String, String>,
274}
275
276#[derive(Debug, Deserialize)]
277struct HttpExpectedResponse {
278    status_code: u16,
279    #[serde(default)]
280    body: Option<serde_json::Value>,
281    #[serde(default)]
282    headers: HashMap<String, String>,
283}
284
285#[derive(Debug, Deserialize)]
286struct HttpFixture {
287    expected_response: HttpExpectedResponse,
288}
289
290#[derive(Debug, Deserialize)]
291struct Fixture {
292    id: String,
293    #[serde(default)]
294    mock_response: Option<MockResponse>,
295    #[serde(default)]
296    http: Option<HttpFixture>,
297}
298
299impl Fixture {
300    /// Bridge both schemas into a unified MockResponse.
301    fn as_mock_response(&self) -> Option<MockResponse> {
302        if let Some(mock) = &self.mock_response {
303            return Some(MockResponse {
304                status: mock.status,
305                body: mock.body.clone(),
306                stream_chunks: mock.stream_chunks.clone(),
307                headers: mock.headers.clone(),
308            });
309        }
310        if let Some(http) = &self.http {
311            return Some(MockResponse {
312                status: http.expected_response.status_code,
313                body: http.expected_response.body.clone(),
314                stream_chunks: None,
315                headers: http.expected_response.headers.clone(),
316            });
317        }
318        None
319    }
320}
321
322// ---------------------------------------------------------------------------
323// Route table
324// ---------------------------------------------------------------------------
325
326#[derive(Clone, Debug)]
327struct MockRoute {
328    status: u16,
329    body: String,
330    stream_chunks: Vec<String>,
331    headers: Vec<(String, String)>,
332}
333
334type RouteTable = Arc<HashMap<String, MockRoute>>;
335
336// ---------------------------------------------------------------------------
337// Axum handler
338// ---------------------------------------------------------------------------
339
340async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
341    let path = req.uri().path().to_owned();
342
343    // Try exact match first
344    if let Some(route) = routes.get(&path) {
345        return serve_route(route);
346    }
347
348    // Try prefix match: find a route that is a prefix of the request path
349    // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
350    for (route_path, route) in routes.iter() {
351        if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
352            return serve_route(route);
353        }
354    }
355
356    Response::builder()
357        .status(StatusCode::NOT_FOUND)
358        .body(Body::from(format!("No mock route for {path}")))
359        .unwrap()
360        .into_response()
361}
362
363fn serve_route(route: &MockRoute) -> Response {
364    let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
365
366    if !route.stream_chunks.is_empty() {
367        let mut sse = String::new();
368        for chunk in &route.stream_chunks {
369            sse.push_str("data: ");
370            sse.push_str(chunk);
371            sse.push_str("\n\n");
372        }
373        sse.push_str("data: [DONE]\n\n");
374
375        let mut builder = Response::builder()
376            .status(status)
377            .header("content-type", "text/event-stream")
378            .header("cache-control", "no-cache");
379        for (name, value) in &route.headers {
380            builder = builder.header(name, value);
381        }
382        return builder.body(Body::from(sse)).unwrap().into_response();
383    }
384
385    // Only set the default content-type if the fixture does not override it.
386    // Use application/json when the body looks like JSON (starts with { or [),
387    // otherwise fall back to text/plain to avoid clients failing JSON-decode.
388    let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
389    let mut builder = Response::builder().status(status);
390    if !has_content_type {
391        let trimmed = route.body.trim_start();
392        let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
393            "application/json"
394        } else {
395            "text/plain"
396        };
397        builder = builder.header("content-type", default_ct);
398    }
399    for (name, value) in &route.headers {
400        // Skip content-encoding headers — the mock server returns uncompressed bodies.
401        // Sending a content-encoding without actually encoding the body would cause
402        // clients to fail decompression.
403        if name.to_lowercase() == "content-encoding" {
404            continue;
405        }
406        // The <<absent>> sentinel means this header must NOT be present in the
407        // real server response — do not emit it from the mock server either.
408        if value == "<<absent>>" {
409            continue;
410        }
411        // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
412        // assert the header value matches the UUID pattern.
413        if value == "<<uuid>>" {
414            let uuid = format!(
415                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
416                rand_u32(),
417                rand_u16(),
418                rand_u16() & 0x0fff,
419                (rand_u16() & 0x3fff) | 0x8000,
420                rand_u48(),
421            );
422            builder = builder.header(name, uuid);
423            continue;
424        }
425        builder = builder.header(name, value);
426    }
427    builder.body(Body::from(route.body.clone())).unwrap().into_response()
428}
429
430/// Generate a pseudo-random u32 using the current time nanoseconds.
431fn rand_u32() -> u32 {
432    use std::time::{SystemTime, UNIX_EPOCH};
433    let ns = SystemTime::now()
434        .duration_since(UNIX_EPOCH)
435        .map(|d| d.subsec_nanos())
436        .unwrap_or(0);
437    ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
438}
439
440fn rand_u16() -> u16 {
441    (rand_u32() & 0xffff) as u16
442}
443
444fn rand_u48() -> u64 {
445    ((rand_u32() as u64) << 16) | (rand_u16() as u64)
446}
447
448// ---------------------------------------------------------------------------
449// Fixture loading
450// ---------------------------------------------------------------------------
451
452fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
453    let mut routes = HashMap::new();
454    load_routes_recursive(fixtures_dir, &mut routes);
455    routes
456}
457
458fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
459    let entries = match std::fs::read_dir(dir) {
460        Ok(e) => e,
461        Err(err) => {
462            eprintln!("warning: cannot read directory {}: {err}", dir.display());
463            return;
464        }
465    };
466
467    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
468    paths.sort();
469
470    for path in paths {
471        if path.is_dir() {
472            load_routes_recursive(&path, routes);
473        } else if path.extension().is_some_and(|ext| ext == "json") {
474            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
475            if filename == "schema.json" || filename.starts_with('_') {
476                continue;
477            }
478            let content = match std::fs::read_to_string(&path) {
479                Ok(c) => c,
480                Err(err) => {
481                    eprintln!("warning: cannot read {}: {err}", path.display());
482                    continue;
483                }
484            };
485            let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
486                match serde_json::from_str(&content) {
487                    Ok(v) => v,
488                    Err(err) => {
489                        eprintln!("warning: cannot parse {}: {err}", path.display());
490                        continue;
491                    }
492                }
493            } else {
494                match serde_json::from_str::<Fixture>(&content) {
495                    Ok(f) => vec![f],
496                    Err(err) => {
497                        eprintln!("warning: cannot parse {}: {err}", path.display());
498                        continue;
499                    }
500                }
501            };
502
503            for fixture in fixtures {
504                if let Some(mock) = fixture.as_mock_response() {
505                    let route_path = format!("/fixtures/{}", fixture.id);
506                    let body = mock
507                        .body
508                        .as_ref()
509                        .map(|b| match b {
510                            // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
511                            // fixtures. Return the raw value so clients receive the string itself,
512                            // not its JSON-encoded form with extra surrounding quotes.
513                            serde_json::Value::String(s) => s.clone(),
514                            other => serde_json::to_string(other).unwrap_or_default(),
515                        })
516                        .unwrap_or_default();
517                    let stream_chunks = mock
518                        .stream_chunks
519                        .unwrap_or_default()
520                        .into_iter()
521                        .map(|c| match c {
522                            serde_json::Value::String(s) => s,
523                            other => serde_json::to_string(&other).unwrap_or_default(),
524                        })
525                        .collect();
526                    let mut headers: Vec<(String, String)> =
527                        mock.headers.into_iter().collect();
528                    headers.sort_by(|a, b| a.0.cmp(&b.0));
529                    routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
530                }
531            }
532        }
533    }
534}
535
536// ---------------------------------------------------------------------------
537// Entry point
538// ---------------------------------------------------------------------------
539
540#[tokio::main]
541async fn main() {
542    let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
543    let fixtures_dir = Path::new(&fixtures_dir_arg);
544
545    let routes = load_routes(fixtures_dir);
546    eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
547
548    let route_table: RouteTable = Arc::new(routes);
549    let app = Router::new().fallback(handle_request).with_state(route_table);
550
551    let listener = TcpListener::bind("127.0.0.1:0")
552        .await
553        .expect("mock-server: failed to bind port");
554    let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
555
556    // Print the URL so the parent process can read it.
557    println!("MOCK_SERVER_URL=http://{addr}");
558    // Flush stdout explicitly so the parent does not block waiting.
559    use std::io::Write;
560    std::io::stdout().flush().expect("mock-server: failed to flush stdout");
561
562    // Spawn the server in the background.
563    tokio::spawn(async move {
564        axum::serve(listener, app).await.expect("mock-server: server error");
565    });
566
567    // Block until stdin is closed — the parent process controls lifetime.
568    let stdin = io::stdin();
569    let mut lines = stdin.lock().lines();
570    while lines.next().is_some() {}
571}
572"#
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn render_mock_server_module_contains_struct_definition() {
581        let out = render_mock_server_module();
582        assert!(out.contains("pub struct MockRoute"));
583        assert!(out.contains("pub struct MockServer"));
584    }
585
586    #[test]
587    fn render_mock_server_binary_contains_main() {
588        let out = render_mock_server_binary();
589        assert!(out.contains("async fn main()"));
590        assert!(out.contains("MOCK_SERVER_URL=http://"));
591    }
592}