Der nächste Layer ist jetzt als echte gemeinsame Außenkante umgesetzt. Das neue API-Crate in [server.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/server.rs>), [dto.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/dto.rs>) und [main.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/main.rs>) stellt die geforderten versionierten Endpunkte bereit: GET /api/v1/snapshot, GET /api/v1/catalog, GET /api/v1/presets, GET /api/v1/groups, POST /api/v1/command und WS /api/v1/stream. Die API trennt jetzt sauber zwischen Command-, State-, Preview- und Event-Modell, inklusive stabiler Außen-Enums und dokumentierten Fehlerobjekten statt eines unreflektierten 1:1-Core-Leaks.
Darauf sitzt die erste Web-UI V1 direkt gegen diese API in [index.html](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/index.html>), [app.js](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/app.js>) und [styles.css](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/styles.css>). Sie enthält Pattern-/Preset-Auswahl, globale Regler, Gruppenauswahl, Übergänge, Preview, Snapshot-Anzeige und Event-Feed. Die technische Desktop-GUI bleibt bewusst die Engineering-/Diagnoseoberfläche; die kreative Web-UI baut nicht an einer Parallelarchitektur vorbei. Contract-Tests liegen in [contract.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/tests/contract.rs>), und die API ist mit Beispiel-Responses in [docs/host_api.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>) dokumentiert. Zusätzlich habe ich [README.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/README.md>), [docs/build_and_deploy.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/build_and_deploy.md>) und [docs/architecture.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>) auf den neuen Stand gebracht. Verifiziert habe ich: - `cargo check -p infinity_host_api` - `cargo test -p infinity_host_api` - `cargo test -q` Nicht verifiziert habe ich eine separate JS-Syntaxprüfung mit `node --check`, weil `node` in dieser Umgebung nicht installiert ist.
This commit is contained in:
295
crates/infinity_host_api/tests/contract.rs
Normal file
295
crates/infinity_host_api/tests/contract.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
use infinity_config::ProjectConfig;
|
||||
use infinity_host::{HostApiPort, SimulationHostService};
|
||||
use infinity_host_api::HostApiServer;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Shutdown, SocketAddr, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn sample_project() -> ProjectConfig {
|
||||
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
|
||||
.expect("project config must parse")
|
||||
}
|
||||
|
||||
fn start_server() -> HostApiServer {
|
||||
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
|
||||
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
|
||||
}
|
||||
|
||||
struct HttpResponse {
|
||||
status_code: u16,
|
||||
headers: HashMap<String, String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_serves_creative_console_shell() {
|
||||
let server = start_server();
|
||||
let response = send_http_request(server.local_addr(), "GET", "/", None);
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
assert!(response
|
||||
.headers
|
||||
.get("content-type")
|
||||
.expect("content-type header")
|
||||
.starts_with("text/html"));
|
||||
assert!(response.body.contains("Infinity Vis / Creative Surface"));
|
||||
assert!(response.body.contains("/app.js"));
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_endpoint_is_versioned_and_separates_state_and_preview() {
|
||||
let server = start_server();
|
||||
let response = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
|
||||
let body: Value = serde_json::from_str(&response.body).expect("snapshot json must parse");
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
assert_eq!(body["api_version"], "v1");
|
||||
assert_eq!(body["state"]["system"]["project_name"], "Infinity Vis");
|
||||
assert_eq!(body["state"]["nodes"].as_array().map(Vec::len), Some(6));
|
||||
assert_eq!(body["preview"]["panels"].as_array().map(Vec::len), Some(18));
|
||||
assert!(body["state"]["active_scene"]["pattern_id"].is_string());
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_presets_and_groups_endpoints_return_expected_lists() {
|
||||
let server = start_server();
|
||||
|
||||
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
|
||||
let presets = send_http_request(server.local_addr(), "GET", "/api/v1/presets", None);
|
||||
let groups = send_http_request(server.local_addr(), "GET", "/api/v1/groups", None);
|
||||
|
||||
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
|
||||
let preset_body: Value = serde_json::from_str(&presets.body).expect("preset json");
|
||||
let group_body: Value = serde_json::from_str(&groups.body).expect("group json");
|
||||
|
||||
assert_eq!(catalog.status_code, 200);
|
||||
assert!(catalog_body["patterns"]
|
||||
.as_array()
|
||||
.expect("patterns array")
|
||||
.iter()
|
||||
.any(|pattern| pattern["pattern_id"] == "walking_pixel"));
|
||||
assert!(preset_body["presets"]
|
||||
.as_array()
|
||||
.expect("presets array")
|
||||
.iter()
|
||||
.any(|preset| preset["preset_id"] == "ocean_gradient"));
|
||||
assert!(group_body["groups"]
|
||||
.as_array()
|
||||
.expect("groups array")
|
||||
.iter()
|
||||
.any(|group| group["group_id"] == "top_panels"));
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_endpoint_applies_state_changes_and_rejects_invalid_payload() {
|
||||
let server = start_server();
|
||||
|
||||
let response = send_http_request(
|
||||
server.local_addr(),
|
||||
"POST",
|
||||
"/api/v1/command",
|
||||
Some(
|
||||
r#"{
|
||||
"request_id": "contract-blackout",
|
||||
"command": {
|
||||
"type": "set_blackout",
|
||||
"payload": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
),
|
||||
);
|
||||
let response_body: Value =
|
||||
serde_json::from_str(&response.body).expect("command response must parse");
|
||||
let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
|
||||
let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
assert_eq!(response_body["accepted"], true);
|
||||
assert_eq!(response_body["request_id"], "contract-blackout");
|
||||
assert_eq!(snapshot_body["state"]["global"]["blackout"], true);
|
||||
|
||||
let invalid = send_http_request(
|
||||
server.local_addr(),
|
||||
"POST",
|
||||
"/api/v1/command",
|
||||
Some(r#"{"command":{"type":"set_blackout","payload":{}}}"#),
|
||||
);
|
||||
let invalid_body: Value =
|
||||
serde_json::from_str(&invalid.body).expect("invalid response must parse");
|
||||
|
||||
assert_eq!(invalid.status_code, 400);
|
||||
assert_eq!(invalid_body["api_version"], "v1");
|
||||
assert_eq!(invalid_body["error"]["code"], "invalid_command");
|
||||
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_stream_emits_snapshot_preview_and_event_messages() {
|
||||
let server = start_server();
|
||||
let mut stream = open_websocket(server.local_addr());
|
||||
|
||||
let first_frame = read_websocket_text_frame(&mut stream);
|
||||
let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame");
|
||||
assert_eq!(first_payload["message"]["type"], "snapshot");
|
||||
|
||||
let second_frame = read_websocket_text_frame(&mut stream);
|
||||
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
|
||||
assert_eq!(second_payload["message"]["type"], "preview");
|
||||
|
||||
let _ = send_http_request(
|
||||
server.local_addr(),
|
||||
"POST",
|
||||
"/api/v1/command",
|
||||
Some(
|
||||
r#"{
|
||||
"request_id": "contract-event",
|
||||
"command": {
|
||||
"type": "set_blackout",
|
||||
"payload": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
),
|
||||
);
|
||||
|
||||
let mut saw_event = false;
|
||||
for _ in 0..8 {
|
||||
let frame = read_websocket_text_frame(&mut stream);
|
||||
let payload: Value = serde_json::from_str(&frame).expect("ws event frame");
|
||||
if payload["message"]["type"] == "event" {
|
||||
saw_event = true;
|
||||
assert!(payload["message"]["payload"]["message"]
|
||||
.as_str()
|
||||
.expect("event message")
|
||||
.contains("blackout"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_event, "expected websocket event after command");
|
||||
|
||||
let _ = stream.shutdown(Shutdown::Both);
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
fn send_http_request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> HttpResponse {
|
||||
let body = body.unwrap_or("");
|
||||
let request = format!(
|
||||
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.as_bytes().len(),
|
||||
body,
|
||||
host = addr
|
||||
);
|
||||
|
||||
let mut stream = TcpStream::connect(addr).expect("http connection");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.expect("read timeout");
|
||||
stream.write_all(request.as_bytes()).expect("write request");
|
||||
stream.shutdown(Shutdown::Write).expect("shutdown write");
|
||||
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).expect("read response");
|
||||
parse_http_response(&raw)
|
||||
}
|
||||
|
||||
fn parse_http_response(raw: &[u8]) -> HttpResponse {
|
||||
let delimiter = raw
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.expect("http header delimiter");
|
||||
let header_text = String::from_utf8(raw[..delimiter].to_vec()).expect("header utf8");
|
||||
let body = String::from_utf8(raw[delimiter + 4..].to_vec()).expect("body utf8");
|
||||
let mut lines = header_text.lines();
|
||||
let status_line = lines.next().expect("status line");
|
||||
let status_code = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.expect("status code")
|
||||
.parse::<u16>()
|
||||
.expect("valid status code");
|
||||
let headers = lines
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
HttpResponse {
|
||||
status_code,
|
||||
headers,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_websocket(addr: SocketAddr) -> TcpStream {
|
||||
let mut stream = TcpStream::connect(addr).expect("websocket connection");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.expect("read timeout");
|
||||
let request = format!(
|
||||
"GET /api/v1/stream HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
|
||||
host = addr
|
||||
);
|
||||
stream.write_all(request.as_bytes()).expect("write handshake");
|
||||
|
||||
let header = read_until_header_end(&mut stream);
|
||||
let header_text = String::from_utf8(header).expect("handshake utf8");
|
||||
assert!(header_text.starts_with("HTTP/1.1 101"));
|
||||
assert!(header_text.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
|
||||
stream
|
||||
}
|
||||
|
||||
fn read_until_header_end(stream: &mut TcpStream) -> Vec<u8> {
|
||||
let mut buffer = Vec::new();
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
let read = stream.read(&mut byte).expect("read handshake");
|
||||
assert!(read > 0, "unexpected eof while reading handshake");
|
||||
buffer.push(byte[0]);
|
||||
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let end = buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.expect("header end")
|
||||
+ 4;
|
||||
return buffer[..end].to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
|
||||
let mut header = [0u8; 2];
|
||||
stream.read_exact(&mut header).expect("frame header");
|
||||
assert_eq!(header[0] & 0x0f, 0x1, "expected text frame");
|
||||
|
||||
let payload_len = match header[1] & 0x7f {
|
||||
len @ 0..=125 => len as usize,
|
||||
126 => {
|
||||
let mut extended = [0u8; 2];
|
||||
stream.read_exact(&mut extended).expect("extended payload");
|
||||
u16::from_be_bytes(extended) as usize
|
||||
}
|
||||
127 => {
|
||||
let mut extended = [0u8; 8];
|
||||
stream.read_exact(&mut extended).expect("extended payload");
|
||||
u64::from_be_bytes(extended) as usize
|
||||
}
|
||||
_ => unreachable!("masked length bit should already be stripped"),
|
||||
};
|
||||
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
stream.read_exact(&mut payload).expect("frame payload");
|
||||
String::from_utf8(payload).expect("frame utf8")
|
||||
}
|
||||
Reference in New Issue
Block a user