diff --git a/src/v3/compiler/src/omni_shape_b_openapi.rs b/src/v3/compiler/src/omni_shape_b_openapi.rs index 241f69b6940..1c179ec183b 100644 --- a/src/v3/compiler/src/omni_shape_b_openapi.rs +++ b/src/v3/compiler/src/omni_shape_b_openapi.rs @@ -250,6 +250,101 @@ pub fn project_markdown_documentation(dag: &Dag) -> Result Result { + let routes = extract_rest_routes(dag)?; + let mut out = String::from( + r#"const ROUTES: &[(&str, &str)] = &[ +"#, + ); + for route in &routes { + out.push_str(" ("); + out.push_str(&rust_string_literal(&route.method)); + out.push_str(", "); + out.push_str(&rust_string_literal(&route.path)); + out.push_str("),\n"); + } + out.push_str( + r#"]; + +fn main() { + let mut args = std::env::args().skip(1); + let Some(method) = args.next() else { + print_routes(); + return; + }; + let Some(path) = args.next() else { + print_routes(); + return; + }; + + for (route_method, route_template) in ROUTES { + if method.eq_ignore_ascii_case(route_method) && path_matches(route_template, &path) { + println!("200 {} {}", route_method, route_template); + return; + } + } + println!("404 {} {}", method.to_ascii_uppercase(), path); +} + +fn print_routes() { + for (method, route_template) in ROUTES { + println!("{} {}", method, route_template); + } +} + +fn path_matches(template: &str, candidate: &str) -> bool { + let template = template.as_bytes(); + let candidate = candidate.as_bytes(); + let mut t = 0; + let mut c = 0; + while t < template.len() { + if template[t] == b'{' { + let Some(close_offset) = template[t + 1..].iter().position(|byte| *byte == b'}') else { + return false; + }; + let close = t + 1 + close_offset; + let after_param = close + 1; + if after_param == template.len() { + return c < candidate.len(); + } + let next_param = template[after_param..] + .iter() + .position(|byte| *byte == b'{') + .map(|offset| after_param + offset) + .unwrap_or(template.len()); + let literal = &template[after_param..next_param]; + let Some(next_match) = find_subslice(&candidate[c..], literal) else { + return false; + }; + if next_match == 0 { + return false; + } + c += next_match; + t = after_param; + } else { + if candidate.get(c) != Some(&template[t]) { + return false; + } + t += 1; + c += 1; + } + } + c == candidate.len() +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} +"#, + ); + Ok(out) +} + fn append_path_parameter_yaml(out: &mut String, parameter: &str) { out.push_str(" - name: "); out.push_str(&yaml_double_quoted(parameter)); @@ -468,6 +563,25 @@ fn yaml_plain_operation_id(method: &str, path: &str) -> String { format!("{}_{}", method.to_ascii_lowercase(), suffix) } +fn rust_string_literal(value: &str) -> String { + let mut literal = String::from("\""); + for ch in value.chars() { + match ch { + '\\' => literal.push_str("\\\\"), + '"' => literal.push_str("\\\""), + '\n' => literal.push_str("\\n"), + '\r' => literal.push_str("\\r"), + '\t' => literal.push_str("\\t"), + other if other.is_control() => { + write!(&mut literal, "\\u{{{:X}}}", other as u32).expect("write to String"); + } + other => literal.push(other), + } + } + literal.push('"'); + literal +} + fn markdown_table_cell(value: &str) -> String { value.replace('\\', "\\\\").replace('|', "\\|") } @@ -613,4 +727,9 @@ data service_operations: List = [ assert_eq!(markdown_code_span_table_cell("`x"), "`` `x ``"); assert_eq!(markdown_code_span_table_cell("x`"), "`` x` ``"); } + + #[test] + fn rust_backend_projection_escapes_route_literals() { + assert_eq!(rust_string_literal("/a\"b\\c\n"), "\"/a\\\"b\\\\c\\n\""); + } } diff --git a/src/v3/compiler/tests/integration/m1_5_omni_shape_b_openapi_test.rs b/src/v3/compiler/tests/integration/m1_5_omni_shape_b_openapi_test.rs index 12630e16dcf..009f9dc1b32 100644 --- a/src/v3/compiler/tests/integration/m1_5_omni_shape_b_openapi_test.rs +++ b/src/v3/compiler/tests/integration/m1_5_omni_shape_b_openapi_test.rs @@ -2,17 +2,47 @@ //! //! Brief #2219 receipt: one lowered endpoint-bearing fixture feeds the Shape B //! OpenAPI 3.1 YAML projection, the Shape B Markdown drift-lock projection, and -//! the canonical route projection used as the interim backend exposure set. The -//! cross-target equality test is interim until a cross-target TestPredicate -//! variant exists. +//! a runnable Rust backend service projection. use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; use v3_compiler::compile_to_dag; use v3_compiler::omni_shape_b_openapi::{ - extract_rest_routes, project_markdown_documentation, project_openapi_yaml, RestRoute, + extract_rest_routes, project_markdown_documentation, project_openapi_yaml, + project_rust_backend_service, RestRoute, }; +static BACKEND_ROUNDTRIP_ID: AtomicUsize = AtomicUsize::new(0); + +struct TmpDir(PathBuf); + +impl TmpDir { + fn new() -> Self { + let id = BACKEND_ROUNDTRIP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "v3_omni_openapi_backend_{}_{}", + std::process::id(), + id + )); + std::fs::create_dir_all(&path).expect("create tmp dir"); + TmpDir(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TmpDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + const OMNI_SERVICE_FIXTURE: &str = r#" module t.openapi_demo @@ -304,6 +334,61 @@ fn yaml_scalar_value(value: &str) -> String { value.to_string() } +fn compile_backend_service(source: &str) -> (TmpDir, PathBuf) { + let tmp_dir = TmpDir::new(); + let src_path = tmp_dir.path().join("backend.rs"); + let bin_path = tmp_dir.path().join("backend_bin"); + std::fs::File::create(&src_path) + .and_then(|mut f| f.write_all(source.as_bytes())) + .expect("write backend source"); + + let compile = Command::new("rustc") + .env_remove("RUSTC_BOOTSTRAP") + .arg("--edition=2021") + .arg(&src_path) + .arg("-o") + .arg(&bin_path) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .expect("invoke rustc"); + assert!( + compile.success(), + "rustc failed on emitted backend source:\n{source}" + ); + (tmp_dir, bin_path) +} + +fn backend_stdout(bin_path: &Path, args: &[&str]) -> String { + let run = Command::new(bin_path) + .args(args) + .output() + .expect("run emitted backend"); + assert!( + run.status.success(), + "emitted backend exited non-zero; stderr:\n{}", + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).trim().to_string() +} + +fn backend_routes(source: &str) -> BTreeSet { + let (_tmp_dir, bin_path) = compile_backend_service(source); + backend_stdout(&bin_path, &[]) + .lines() + .map(|line| { + let (method, path) = line + .split_once(' ') + .expect("backend route listing is `METHOD PATH`"); + RestRoute { + method: method.to_string(), + path: path.to_string(), + path_parameters: vec![], + } + }) + .collect() +} + #[test] fn markdown_documentation_parser_round_trips_escaped_table_cells() { let markdown = concat!( @@ -343,6 +428,8 @@ fn omni_layers_share_one_node_tree() { let _canonical_routes = extract_rest_routes(&dag).expect("canonical route projection extracts"); let _openapi = project_openapi_yaml(&dag).expect("Shape B OpenAPI projects from shared DAG"); + let _backend = + project_rust_backend_service(&dag).expect("Rust backend projects from shared DAG"); let _markdown = project_markdown_documentation(&dag).expect("Shape B Markdown projects from shared DAG"); @@ -374,19 +461,58 @@ fn shape_b_openapi_projection_produces_3_1_yaml_for_rest_operations() { } #[test] -fn openapi_routes_match_canonical_dag_routes_interim() { +fn openapi_routes_match_emitted_backend_routes() { let dag = compile_omni_service_fixture(); - // Interim until cross-target TestPredicate exists: both projections are - // extracted from the same compiled DAG and compared here in `tests/`. - // Rust target does not yet expose a structured route projection, so this - // shared DAG extraction is the canonical backend exposure set for Brief #1. let canonical_routes = extract_rest_routes(&dag).expect("canonical route projection extracts"); let openapi_routes = openapi_yaml_routes(&project_openapi_yaml(&dag).expect("OpenAPI YAML projects")); + let backend_routes = + backend_routes(&project_rust_backend_service(&dag).expect("Rust backend service projects")); + let backend_routes_without_parameters: BTreeSet<_> = canonical_routes + .iter() + .map(|route| RestRoute { + method: route.method.clone(), + path: route.path.clone(), + path_parameters: vec![], + }) + .collect(); assert_eq!(canonical_routes, expected_routes()); assert_eq!(openapi_routes, canonical_routes); + assert_eq!(backend_routes, backend_routes_without_parameters); +} + +#[test] +fn emitted_backend_service_runs_declared_routes() { + let dag = compile_omni_service_fixture(); + let source = project_rust_backend_service(&dag).expect("Rust backend service projects"); + let (_tmp_dir, bin_path) = compile_backend_service(&source); + + assert_eq!( + backend_stdout(&bin_path, &["GET", "/users"]), + "200 GET /users" + ); + assert_eq!( + backend_stdout(&bin_path, &["POST", "/users"]), + "200 POST /users" + ); + assert_eq!( + backend_stdout(&bin_path, &["GET", "/users/42"]), + "200 GET /users/{id}" + ); + assert_eq!( + backend_stdout(&bin_path, &["GET", "/orgs/gunb/repos/gunbc"]), + "200 GET /orgs/{org}/repos/{repo}" + ); + assert_eq!( + backend_stdout(&bin_path, &["POST", "/secrets/api_key:addVersion"]), + "200 POST /secrets/{secret_name}:addVersion" + ); + assert_eq!( + backend_stdout(&bin_path, &["DELETE", "/users"]), + "404 DELETE /users" + ); } #[test]