Files
zeekay c7694a7285 zip in Rust serves on hyper, and an op can stream
An op is an async fn. It may take &zip::Cx (request headers; the
answer's status, headers and trailers) and a zip::Body read as it
arrives, and may answer zip::Sse or zip::Body, each chunk written as
the op yields it. A client that leaves drops the op's stream.

The blocking std::net door is gone: HTTP/1.1 keep-alive and h2c on
one port (hyper_util auto), graceful shutdown bounded by a grace.
The ZAP door runs on tokio and streams with zap-proto/http's head,
data and end frames.

Wire names are serde's: the derive reads rename, rename_all, skip
and with, and the hand-rolled JSON is deleted. The info manifest
comes out byte for byte as before.

No zapgen emits Rust, so the crate never built; the frame codec is
written in zap.rs and pinned to frames Go wrote. The length prefix
is big-endian, as zap-proto/http's transport writes it.

examples/stream is a JSON op, an SSE op with a trailer and a byte
relay, tested over raw HTTP/1.1, h2c and ZAP sockets.

Claude-Session: https://claude.ai/code/session_01SCPMGSQsLNF2YtHFL4bwyp
2026-10-08 12:56:07 -07:00

170 lines
6.0 KiB
Rust

// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The two doors answer the same ops.
//!
//! A REST request and a ZAP frame reach one handler, so the test asks both and
//! compares what came back. It is the only way to know the claim is true: two
//! doors that each pass their own test can still be two services.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::Arc;
use std::time::Duration;
fn doors() -> (String, String) {
let http = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let zap = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addrs = (
http.local_addr().unwrap().to_string(),
zap.local_addr().unwrap().to_string(),
);
http.set_nonblocking(true).unwrap();
zap.set_nonblocking(true).unwrap();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
let app = Arc::new(info::service("luxd/1.36.178", 96369));
let http = tokio::net::TcpListener::from_std(http).unwrap();
let zap = tokio::net::TcpListener::from_std(zap).unwrap();
let grace = Duration::from_secs(1);
let never = std::future::pending::<()>;
let _ = tokio::join!(
zip::http::serve(Arc::clone(&app), http, never(), grace),
zip::zaphttp::serve(app, zap, never(), grace),
);
});
});
addrs
}
/// over_http asks one question in text and answers with the body.
fn over_http(addr: &str, target: &str) -> (u16, String) {
let mut c = TcpStream::connect(addr).unwrap();
let req = format!("GET {target} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
c.write_all(req.as_bytes()).unwrap();
let mut all = String::new();
c.read_to_string(&mut all).unwrap();
let status: u16 = all.split_whitespace().nth(1).unwrap().parse().unwrap();
let body = all.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
(status, body)
}
/// over_zap asks the same question in ZAP frames and answers with the body.
fn over_zap(addr: &str, target: &str) -> (u16, String) {
let mut c = TcpStream::connect(addr).unwrap();
let frame = zip::zap::request("GET", target, &[], &[]);
c.write_all(&(frame.len() as u32).to_be_bytes()).unwrap();
c.write_all(&frame).unwrap();
let mut head = [0u8; 4];
c.read_exact(&mut head).unwrap();
let mut body = vec![0u8; u32::from_be_bytes(head) as usize];
c.read_exact(&mut body).unwrap();
// The answer is a ZAP message: the magic is there, the flags say what kind
// of frame it is, and the fields are read where they lie.
assert_eq!(&body[0..4], b"ZAP\0", "the answer is not a ZAP message");
assert_eq!(
zip::zap::kind(&body),
Some(zip::zap::FRAME_RESPONSE),
"not a response frame"
);
let r = zip::zap::Response::wrap(&body).unwrap();
(r.status(), String::from_utf8(r.body().to_vec()).unwrap())
}
#[test]
fn both_doors_answer_the_same_op() {
let (http, zap) = doors();
for target in [
"/node/version",
"/chains",
"/chain/bootstrapped?chain=X",
"/peers",
"/lps",
"/vms",
"/fees",
] {
let a = over_http(&http, target);
let b = over_zap(&zap, target);
assert_eq!(a.0, 200, "http {target}: {}", a.1);
assert_eq!(a, b, "the two doors disagree about {target}");
}
}
/// A quoted decimal is a string on the wire and eight bytes to a layout, and
/// this is the half a reader sees.
#[test]
fn a_count_is_carried_as_text() {
let (http, _) = doors();
let (status, body) = over_http(&http, "/fees");
assert_eq!(status, 200);
assert!(body.contains(r#""txFee":"1000000""#), "{body}");
}
/// The URL is the whole input of a bodyless op, so a query value binds.
#[test]
fn a_query_value_binds() {
let (http, _) = doors();
assert_eq!(
over_http(&http, "/chain/bootstrapped?chain=X"),
(200, r#"{"isBootstrapped":true}"#.to_string())
);
// A field the op declared it cannot run without is refused by the binder,
// in the words the document already used for it, before the handler runs.
let (status, body) = over_http(&http, "/chain/bootstrapped");
assert_eq!(status, 400, "{body}");
assert_eq!(
body,
"{\"detail\":\"field \\\"chain\\\" is required\",\"status\":400,\"title\":\"Bad Request\",\"type\":\"about:blank\"}"
);
}
/// A list rides one URL value, comma-separated, which is what the document
/// already publishes for a repeated parameter.
#[test]
fn a_list_rides_one_value() {
let (http, _) = doors();
let (status, body) = over_http(&http, "/peers?nodeIDs=NodeID-a,NodeID-b");
assert_eq!(status, 200, "{body}");
assert!(body.starts_with(r#"{"numPeers":"1""#), "{body}");
}
/// The service publishes the document it implements.
#[test]
fn the_document_is_served() {
let (http, _) = doors();
let (status, body) = over_http(&http, "/openapi.json");
assert_eq!(status, 200);
assert!(
body.contains(r#""operationId": "get_chain_bootstrapped""#),
"{body}"
);
let (status, tools) = over_http(&http, "/mcp.json");
assert_eq!(status, 200);
assert!(tools.contains(r#""name": "get_fees""#), "{tools}");
}
/// An address nothing registered is a refusal, and a method the address does
/// not answer is a different one.
#[test]
fn an_unknown_address_is_refused() {
let (http, _) = doors();
assert_eq!(over_http(&http, "/nope").0, 404);
}
/// The documents beside the source were projected from THIS declaration: the
/// manifest the crate compiles in is the one zipc was given.
#[test]
fn the_documents_are_current() {
let written =
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/gen/manifest.json")).unwrap();
assert_eq!(
written.trim_end(),
info::ops::Info::manifest(),
"the declaration changed since `make gen`"
);
}