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
This commit is contained in:
2026-10-08 12:56:07 -07:00
parent dc323c13d3
commit c7694a7285
42 changed files with 4444 additions and 1320 deletions
+54 -1
View File
@@ -14,8 +14,11 @@ Checkout: `~/work/zap/zip` (`~/work/zap-proto` links to `~/work/zap`). `~/work/h
| race | `go test -race ./...` |
| collector module | `cd contract && go test -count=1 ./...` |
| example module | `cd examples/local-service && go tool zipdoc -check && go test -count=1 ./...` |
| Rust, every crate | `cd rust && cargo test --workspace` (about 2 s once built) |
| Rust lint | `cd rust && cargo clippy --workspace --all-targets && cargo fmt --all -- --check` |
| Rust documents | `cd rust/examples/<info\|stream> && make gen` (needs Go: `zipc` is this checkout's `cmd/zipc`) |
`./...` does not reach `contract/` or `examples/local-service/`. Each has its own go.mod with a `replace` pointing at this checkout, so it tests the working tree. CI is `hanzo.yml`, run by `.hanzo/workflows/cicd.yml` through `hanzoai/ci`: vet, the root tests and both module checks.
`./...` does not reach `contract/`, `examples/local-service/` or `rust/`. Each has its own go.mod with a `replace` pointing at this checkout, so it tests the working tree. CI is `hanzo.yml`, run by `.hanzo/workflows/cicd.yml` through `hanzoai/ci`: vet, the root tests and both module checks.
`TestSDK_CallsTheLiveServiceOverZAP` and `TestProseReachesAServiceInPackageMain` compile programs offline against this checkout (`GOPROXY=off`, zip's go.sum) and skip under `-short`.
@@ -46,6 +49,8 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
| `middleware/`, `wsx/`, `js/` | middleware; WebSocket upgrades; goja handlers |
| `contract/` | collector contract tests, a module of its own |
| `examples/local-service/` | the README's service, a module of its own |
| `rust/zip`, `rust/zip-macros` | zip in Rust: `#[zip::ops]`, `#[derive(zip::Wire)]`, the hyper and ZAP doors. See Rust below. |
| `rust/examples/info`, `rust/examples/stream` | the conformance corpus in Rust; a streaming service (JSON, SSE, byte relay) and its socket tests |
## Invariants
@@ -65,6 +70,54 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
- `app.Test` runs `prepare`, so `/mcp`, the OpenAPI document and the call plane answer under test as they do when serving.
- The `/docs` page loads Swagger UI from cdn.jsdelivr.net. Embedding swagger-ui-dist 5 would add about 1.74 MB (bundle 1,552,209 bytes, CSS 185,784 bytes, Apache-2.0) to every binary that links zip.
- A manifest op may say `"stream": "sse"` or `"bytes"` (the answer is written as produced, and `ProjectOpenAPI` publishes `text/event-stream` or `application/octet-stream` for it) and `"raw": true` (the request body is bytes, bound to no type). `Check` refuses a stream beside an `out` and a raw body beside an `in`. Go's own typed ops set neither; the Rust front end sets both.
## Rust
`rust/` is a Cargo workspace: `zip` (runtime), `zip-macros` (the two macros), `examples/info` (the corpus, compared byte for byte with `corpus/info` by `conformance_test.go`) and `examples/stream`. It is not published to crates.io; a service takes it as a git dependency pinned by commit, under the key `zip`, since the macros expand to `::zip::` paths (a service that also needs crates.io's `zip` archive crate renames that one):
```toml
zip = { git = "https://github.com/zap-proto/zip", rev = "<commit>" }
```
An op is an `async fn` in an `#[zip::ops]` impl block, with a route attribute and a doc comment. It takes `&self`, optionally `cx: &zip::Cx`, and at most one input: a `Wire + Deserialize` type bound from body, query, path and declared headers, or `body: zip::Body`, the request body as it arrives. It answers `Result<T, zip::Error>`: a `Wire + Serialize` type (200 JSON), `()` (204), `zip::Sse` or `zip::Body`.
```rust
#[zip::ops(app = "chat", title = "Chat", version = "1.0.0")]
impl Chat {
/// Complete streams a completion as server-sent events.
#[post("/v1/chat/completions")]
async fn complete(&self, cx: &zip::Cx, arg: &Request) -> Result<zip::Sse, zip::Error> {
let key = cx.header("authorization").ok_or(zip::Error::new(401, "no key"))?;
let usage = cx.trailer("x-usage")?; // declared now, set at the end
let events = self.upstream(key, arg).await? // impl Stream<Item = Result<Event, E>>
.map(|chunk| chunk.map(|c| zip::Event::data(c)));
Ok(zip::Sse::new(events).keep(Duration::from_secs(15)))
}
/// Relay passes bytes through, both ways, as they arrive.
#[post("/v1/relay")]
async fn relay(&self, cx: &zip::Cx, body: zip::Body) -> Result<zip::Body, zip::Error> {
cx.set_header("content-type", "text/event-stream")?;
Ok(body)
}
}
```
Serve with `zip::listen(app, Some(":8000"), Some(":9653"), zip::signal(), grace).await`, or one door with `zip::http::serve(Arc<App>, TcpListener, shutdown, grace)` / `zip::zaphttp::serve(...)`. `rust/examples/stream/src/lib.rs` is the worked example and `tests/serve.rs` its socket tests.
- Names are serde's. `#[derive(zip::Wire)]` reads `#[serde(rename)]`, `rename_all`, `skip` and `with`; it refuses `flatten`, a rename that differs by direction, `#[zip(json = "name")]` on a field and a `Vec<u8>` without `#[serde(with = "zip::base64")]` (serde writes bytes as a number array; the document says base64). `#[zip(...)]` holds what serde cannot: `url`, `header`, `required`, `text`, and a type's own schema (`json = r#"{...}"#`). A `text` newtype gets its serde impls from the derive (Display and FromStr). `serde_json::Value` is described as `prim: any`. A field the body may omit is an `Option` or carries `#[serde(default)]`; serde refuses a missing one otherwise.
- `bind` reads the body straight into the type when no URL value, declared header or required field applies; otherwise through a `serde_json::Value` it merges them into. A body past `MAX_BODY` (32 MiB) is 413, by its Content-Length before any byte is read.
- A stream item is a `Result`. `Ok` is written and flushed as yielded; `Err` ends the answer without its terminator (HTTP/1.1 truncated chunked body, HTTP/2 reset, ZAP no end frame), since the status already went out. A producer that wants to report in words sends an event and ends.
- Client gone: hyper drops the response body and with it the op's stream. HTTP/1.1 sees it on the read side (hyper's mid-message EOF check, `half_close` off), so an idle producer is dropped too: 1 to 3 ms in `tests/serve.rs`. `Sse::keep` writes `:` comment lines in quiet intervals for proxies.
- Trailers: `cx.trailer(name)` declares (`Trailer:` in the head) and returns a handle the stream keeps; set values go after the last chunk. HTTP/2 always carries them. hyper sends HTTP/1.1 trailers only to a request carrying `TE: trailers`, and only declared names. A JSON answer with a declared trailer goes chunked so it can carry one. Over ZAP they ride the end frame's slot (zap-proto/http v0.3.12's Go client reads past them without surfacing them).
- `serve` stops accepting on `shutdown`, lets each connection finish what it is answering, and after `grace` drops the rest (their streams' producers with them). hyper's HTTP/1.1 header read timeout is on (a timer is set).
- HTTP/1.1 keep-alive and HTTP/2 over cleartext (prior knowledge) on one port (`hyper_util` auto). No TLS: the ingress terminates it.
- The ZAP door speaks zap-proto/http's frames: request 0x01, response 0x02, streamed head 0x03, data 0x04, end 0x05, each preceded by its length as a BIG-endian u32 (header blocks are little-endian). The codec is `rust/zip/src/zap.rs`, written by hand because no zapgen emits Rust (zap-proto/go's tests assert the `rust` backend fails); `zap::tests::frames_are_the_ones_go_writes` pins it to frames Go wrote. Before this, the Rust door wrote a little-endian prefix and its build ran `zapgen -lang rust`, so it never built. A Go `zap-proto/http` client reads this door's JSON, streams (chunk by chunk) and refusals.
- A handler that panics answers 500; the server lives.
Bench (dgx, shared: load 12, GPU at 96%; `examples/stream` release, server pinned to 4 Cortex-X925 cores, `bombardier` on 4 others, `POST /v1/echo` with `{"text":"hi"}`, 10 s): 64 connections 1,201,848 requests (120k/s, p50 288 µs, p99 1.78 ms); 256 connections 1,672,052 (167k/s, p50 0.86 ms, p99 32 ms).
## Known issue
`zipdoc -check ./...` from a consumer's module root has reported files as stale that per-package runs call clean, and which files it reported varied between runs (seen in hanzoai/cloud). Run `-check` in each package directory, the way `go generate` runs it.
+448 -6
View File
@@ -3,12 +3,244 @@
version = 4
[[package]]
name = "info"
version = "0.1.0"
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "h2"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "info"
version = "0.2.0"
dependencies = [
"serde",
"tokio",
"zip",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -27,6 +259,95 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "stream"
version = "0.2.0"
dependencies = [
"bytes",
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
"serde",
"tokio",
"zip",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -38,6 +359,83 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -45,17 +443,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zip"
version = "0.1.0"
name = "want"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zip"
version = "0.2.0"
dependencies = [
"bytes",
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
"serde",
"serde_json",
"tokio",
"zip-macros",
]
[[package]]
name = "zip-macros"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+2 -2
View File
@@ -1,8 +1,8 @@
[workspace]
resolver = "2"
members = ["zip", "zip-macros", "examples/info"]
members = ["zip", "zip-macros", "examples/info", "examples/stream"]
[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "BSD-3-Clause-Eco"
+2
View File
@@ -7,3 +7,5 @@ publish = false
[dependencies]
zip = { path = "../../zip" }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.53", features = ["rt-multi-thread", "macros"] }
+4 -8
View File
@@ -11,15 +11,14 @@
# make gen compile, project, and compile again against the projections
# make run serve on the default addresses
# make check the doors answer, and the wire is ZAP
# make wire the ZAP accessors for these ops, for a client that wants them
# gen names a directory as well as a step, and the step is the one meant.
.PHONY: gen run check wire
.PHONY: gen run check
ZIPC ?= zipc
ZAPGEN ?= zapgen
# zipc is this checkout's, so the documents are projected by the projector
# the manifest was written for.
ZIPC ?= go run ../../../cmd/zipc
ROOT := ../..
OUT ?= wire
gen:
cd $(ROOT) && cargo run -q -p info -- --manifest > examples/info/gen/manifest.json
@@ -31,6 +30,3 @@ run:
check:
cd $(ROOT) && cargo test -p info
wire:
$(ZAPGEN) -lang rust -out $(OUT) gen/info.zap
+6 -2
View File
@@ -8,7 +8,8 @@
//! info --http 127.0.0.1:8000 --zap 127.0.0.1:9653
//! ```
fn main() {
#[tokio::main]
async fn main() {
let mut http = Some("127.0.0.1:8000".to_string());
let mut zap = Some("127.0.0.1:9653".to_string());
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -47,7 +48,10 @@ fn main() {
if let Some(a) = &zap {
println!("info: zap on {a}");
}
if let Err(e) = zip::listen(app, http.as_deref(), zap.as_deref()) {
// A pod is sent SIGTERM and given 30 s before SIGKILL; the drain takes 25
// of them, so what is still streaming at the end is cut by us, not the kernel.
let grace = std::time::Duration::from_secs(25);
if let Err(e) = zip::listen(app, http.as_deref(), zap.as_deref(), zip::signal(), grace).await {
eprintln!("info: {e}");
std::process::exit(1);
}
+14 -14
View File
@@ -33,7 +33,7 @@ impl Info {
///
/// Response: {"version": "luxd/1.36.178", "databaseVersion": "v1.4.5", "rpcProtocolVersion": 39, "gitCommit": ""}
#[get("/node/version")]
fn node_version(&self) -> Result<NodeVersion, zip::Error> {
async fn node_version(&self) -> Result<NodeVersion, zip::Error> {
Ok(NodeVersion {
version: self.release.clone(),
database_version: "v1.4.5".into(),
@@ -47,7 +47,7 @@ impl Info {
///
/// Response: {"nodeID": "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg", "nodePOP": {"publicKey": "0x8f95", "proofOfPossession": "0x86a3"}}
#[get("/node/id")]
fn identity(&self) -> Result<NodeID, zip::Error> {
async fn identity(&self) -> Result<NodeID, zip::Error> {
Ok(NodeID {
node_id: "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg".into(),
node_pop: ProofOfPossession {
@@ -61,7 +61,7 @@ impl Info {
///
/// Response: {"ip": "203.0.113.9:9651"}
#[get("/node/ip")]
fn address(&self) -> Result<NodeIP, zip::Error> {
async fn address(&self) -> Result<NodeIP, zip::Error> {
Ok(NodeIP {
ip: "203.0.113.9:9651".into(),
})
@@ -71,7 +71,7 @@ impl Info {
///
/// Response: {"networkID": 96369}
#[get("/network/id")]
fn network_id(&self) -> Result<NetworkID, zip::Error> {
async fn network_id(&self) -> Result<NetworkID, zip::Error> {
Ok(NetworkID {
network_id: self.network,
})
@@ -81,7 +81,7 @@ impl Info {
///
/// Response: {"networkName": "mainnet"}
#[get("/network/name")]
fn network_name(&self) -> Result<NetworkName, zip::Error> {
async fn network_name(&self) -> Result<NetworkName, zip::Error> {
Ok(NetworkName {
network_name: "mainnet".into(),
})
@@ -92,7 +92,7 @@ impl Info {
/// Example: {"alias": "X"}
/// Response: {"blockchainID": "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM"}
#[get("/chain/id")]
fn chain_id(&self, arg: &ChainAlias) -> Result<ChainID, zip::Error> {
async fn chain_id(&self, arg: &ChainAlias) -> Result<ChainID, zip::Error> {
if arg.alias.is_empty() {
return Err(zip::Error::bad("argument 'alias' not given"));
}
@@ -106,7 +106,7 @@ impl Info {
/// Example: {"chain": "X"}
/// Response: {"isBootstrapped": true}
#[get("/chain/bootstrapped")]
fn bootstrapped(&self, arg: &BootstrappedArgs) -> Result<Bootstrapped, zip::Error> {
async fn bootstrapped(&self, arg: &BootstrappedArgs) -> Result<Bootstrapped, zip::Error> {
if arg.chain.is_empty() {
return Err(zip::Error::bad("argument 'chain' not given"));
}
@@ -120,7 +120,7 @@ impl Info {
///
/// Response: {"chains": [{"id": "11111111111111111111111111111111LpoYY", "name": "P-Chain", "vmID": "11111111111111111111111111111111LpoYY", "bootstrapped": true}]}
#[get("/chains")]
fn chains(&self) -> Result<Chains, zip::Error> {
async fn chains(&self) -> Result<Chains, zip::Error> {
Ok(Chains {
chains: vec![Chain {
id: "11111111111111111111111111111111LpoYY".into(),
@@ -137,7 +137,7 @@ impl Info {
/// Example: {"nodeIDs": []}
/// Response: {"numPeers": "1", "peers": [{"ip": "203.0.113.9:9651", "publicIP": "203.0.113.9:9651", "nodeID": "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg", "version": "luxd/1.36.178", "observedUptime": 99, "trackedChains": []}]}
#[get("/peers")]
fn peers(&self, _arg: &PeersArgs) -> Result<Peers, zip::Error> {
async fn peers(&self, _arg: &PeersArgs) -> Result<Peers, zip::Error> {
let peers = vec![Peer {
ip: "203.0.113.9:9651".into(),
public_ip: "203.0.113.9:9651".into(),
@@ -156,7 +156,7 @@ impl Info {
///
/// Response: {"lps": {"23": {"supportWeight": "0", "objectWeight": "0", "abstainWeight": "1000000000000"}}}
#[get("/lps")]
fn lps(&self) -> Result<LPs, zip::Error> {
async fn lps(&self) -> Result<LPs, zip::Error> {
let mut lps = HashMap::new();
lps.insert(
"23".to_string(),
@@ -173,7 +173,7 @@ impl Info {
///
/// Response: {"vms": {"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6": ["platformvm"]}, "fxs": {"spqBHsy2UBGpjaQJezEywUy1AB98eVjJ3WQ38x1vpjsx4xPkY": "secp256k1fx"}}
#[get("/vms")]
fn vms(&self) -> Result<VMs, zip::Error> {
async fn vms(&self) -> Result<VMs, zip::Error> {
let mut vms = HashMap::new();
vms.insert(
"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6".to_string(),
@@ -191,7 +191,7 @@ impl Info {
///
/// Response: {"stopVertexID": "jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga", "epochDuration": "300000000000"}
#[get("/upgrades")]
fn upgrades(&self) -> Result<Upgrades, zip::Error> {
async fn upgrades(&self) -> Result<Upgrades, zip::Error> {
Ok(Upgrades {
stop_vertex_id: "jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga".into(),
epoch_duration: Uint64(300_000_000_000),
@@ -202,7 +202,7 @@ impl Info {
///
/// Response: {"rewardingStakePercentage": "100.0000", "weightedAveragePercentage": "99.9999"}
#[get("/uptime")]
fn uptime(&self) -> Result<Uptime, zip::Error> {
async fn uptime(&self) -> Result<Uptime, zip::Error> {
Ok(Uptime {
rewarding_stake_percentage: "100.0000".into(),
weighted_average_percentage: "99.9999".into(),
@@ -213,7 +213,7 @@ impl Info {
///
/// Response: {"txFee": "1000000", "createAssetTxFee": "10000000", "createChainTxFee": "1000000000"}
#[get("/fees")]
fn fees(&self) -> Result<Fees, zip::Error> {
async fn fees(&self) -> Result<Fees, zip::Error> {
Ok(Fees {
tx_fee: Uint64(1_000_000),
create_asset_tx_fee: Uint64(10_000_000),
+87 -63
View File
@@ -15,6 +15,7 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use zip::Wire;
/// Uint64 is a 64-bit count carried as a quoted decimal, because JSON numbers
@@ -34,232 +35,255 @@ impl From<u64> for Uint64 {
}
/// NodeVersion is what this node is running.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NodeVersion {
/// Version is the node's own release.
#[zip(json = "version")]
#[serde(rename = "version")]
pub version: String,
/// DatabaseVersion is the database format this node reads.
#[zip(json = "databaseVersion")]
#[serde(rename = "databaseVersion")]
pub database_version: String,
/// RPCProtocolVersion is the VM protocol this node speaks.
#[zip(json = "rpcProtocolVersion")]
#[serde(rename = "rpcProtocolVersion")]
pub rpc_protocol_version: u32,
/// GitCommit is the commit this node was built from.
#[zip(json = "gitCommit")]
#[serde(rename = "gitCommit")]
pub git_commit: String,
}
/// NodeID is this node's id, with the proof it holds the staking key.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NodeID {
/// NodeID is the id peers know this node by.
#[zip(json = "nodeID")]
#[serde(rename = "nodeID")]
pub node_id: String,
/// NodePOP is the proof this node holds the key its id is derived from.
#[zip(json = "nodePOP")]
#[serde(rename = "nodePOP")]
pub node_pop: ProofOfPossession,
}
/// ProofOfPossession is a staking key and a signature over it.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProofOfPossession {
/// PublicKey is the BLS public key, hex encoded.
#[zip(json = "publicKey")]
#[serde(rename = "publicKey")]
pub public_key: String,
/// ProofOfPossession is the signature over that key, hex encoded.
#[zip(json = "proofOfPossession")]
#[serde(rename = "proofOfPossession")]
pub proof_of_possession: String,
}
/// NodeIP is where this node tells its peers to reach it.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NodeIP {
/// IP is the address and port peers dial.
#[zip(json = "ip")]
#[serde(rename = "ip")]
pub ip: String,
}
/// NetworkID is the number of the network a node is on.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkID {
/// NetworkID is the network's number.
#[zip(json = "networkID")]
#[serde(rename = "networkID")]
pub network_id: u32,
}
/// NetworkName is the name of the network a node is on.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkName {
/// NetworkName is the network's name.
#[zip(json = "networkName")]
#[serde(rename = "networkName")]
pub network_name: String,
}
/// ChainAlias names the chain a caller is asking about.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ChainAlias {
/// Alias is the name the chain answers to, such as "X".
#[zip(json = "alias", required)]
#[serde(rename = "alias")]
#[zip(required)]
pub alias: String,
}
/// ChainID is the id an alias names.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ChainID {
/// BlockchainID is the chain's id.
#[zip(json = "blockchainID")]
#[serde(rename = "blockchainID")]
pub blockchain_id: String,
}
/// BootstrappedArgs names the chain whose bootstrap state is being asked about.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BootstrappedArgs {
/// Chain is the alias or id of the chain to ask about.
#[zip(json = "chain", required)]
#[serde(rename = "chain")]
#[zip(required)]
pub chain: String,
}
/// Bootstrapped is whether a chain has finished bootstrapping.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Bootstrapped {
/// IsBootstrapped is true once the chain has caught up.
#[zip(json = "isBootstrapped")]
#[serde(rename = "isBootstrapped")]
pub is_bootstrapped: bool,
}
/// Chain is one chain this node runs.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Chain {
/// ID is the chain's id.
#[zip(json = "id")]
#[serde(rename = "id")]
pub id: String,
/// Name is the chain's name.
#[zip(json = "name")]
#[serde(rename = "name")]
pub name: String,
/// VMID is the id of the virtual machine the chain runs.
#[zip(json = "vmID")]
#[serde(rename = "vmID")]
pub vm_id: String,
/// Bootstrapped is whether this node has caught the chain up.
#[zip(json = "bootstrapped")]
#[serde(rename = "bootstrapped")]
pub bootstrapped: bool,
}
/// Chains are the chains this node runs.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Chains {
/// Chains are the chains this node is running.
#[zip(json = "chains")]
#[serde(rename = "chains")]
pub chains: Vec<Chain>,
}
/// PeersArgs narrows the peer list to the nodes named.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PeersArgs {
/// NodeIDs are the peers to answer for; none asks for all of them.
#[zip(json = "nodeIDs")]
#[serde(rename = "nodeIDs")]
pub node_ids: Vec<String>,
}
/// Peer is one connection this node holds.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Peer {
/// IP is the address this node reaches the peer at.
#[zip(json = "ip")]
#[serde(rename = "ip")]
pub ip: String,
/// PublicIP is the address the peer tells others to use.
#[zip(json = "publicIP")]
#[serde(rename = "publicIP")]
pub public_ip: String,
/// NodeID is the peer's id.
#[zip(json = "nodeID")]
#[serde(rename = "nodeID")]
pub node_id: String,
/// Version is the release the peer is running.
#[zip(json = "version")]
#[serde(rename = "version")]
pub version: String,
/// ObservedUptime is the share of time this node has seen the peer up.
#[zip(json = "observedUptime")]
#[serde(rename = "observedUptime")]
pub observed_uptime: u32,
/// TrackedChains are the chains the peer is following.
#[zip(json = "trackedChains")]
#[serde(rename = "trackedChains")]
pub tracked_chains: Vec<String>,
}
/// Peers are the nodes this one is connected to.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Peers {
/// NumPeers is how many connections this node holds.
#[zip(json = "numPeers")]
#[serde(rename = "numPeers")]
pub num_peers: Uint64,
/// Peers are the connections themselves.
#[zip(json = "peers")]
#[serde(rename = "peers")]
pub peers: Vec<Peer>,
}
/// LP is where the stake stands on one proposal.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LP {
/// SupportWeight is the stake in favour.
#[zip(json = "supportWeight")]
#[serde(rename = "supportWeight")]
pub support_weight: Uint64,
/// ObjectWeight is the stake against.
#[zip(json = "objectWeight")]
#[serde(rename = "objectWeight")]
pub object_weight: Uint64,
/// AbstainWeight is the stake that has not said.
#[zip(json = "abstainWeight")]
#[serde(rename = "abstainWeight")]
pub abstain_weight: Uint64,
}
/// LPs is where the network's stake stands on every current proposal.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LPs {
/// LPs are the proposals, by number.
#[zip(json = "lps")]
#[serde(rename = "lps")]
pub lps: HashMap<String, LP>,
}
/// VMs are the virtual machines installed on this node.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct VMs {
/// VMs are the installed machines, by id, each with the names it answers to.
#[zip(json = "vms")]
#[serde(rename = "vms")]
pub vms: HashMap<String, Vec<String>>,
/// Fxs are the feature extensions the UTXO chains understand, by id.
#[zip(json = "fxs")]
#[serde(rename = "fxs")]
pub fxs: HashMap<String, String>,
}
/// Upgrades is the upgrade schedule this node runs.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Upgrades {
/// StopVertexID is the vertex the linearisation stopped at.
#[zip(json = "stopVertexID")]
#[serde(rename = "stopVertexID")]
pub stop_vertex_id: String,
/// EpochDuration is how long one epoch lasts, in nanoseconds.
#[zip(json = "epochDuration")]
#[serde(rename = "epochDuration")]
pub epoch_duration: Uint64,
}
/// Uptime is how much of the network reports having seen this node up.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Uptime {
/// RewardingStakePercentage is the share of stake that would reward this node.
#[zip(json = "rewardingStakePercentage")]
#[serde(rename = "rewardingStakePercentage")]
pub rewarding_stake_percentage: String,
/// WeightedAveragePercentage is the stake-weighted average uptime.
#[zip(json = "weightedAveragePercentage")]
#[serde(rename = "weightedAveragePercentage")]
pub weighted_average_percentage: String,
}
/// Fees are the transaction fees this node charges, in nLUX.
#[derive(Wire, Default)]
#[derive(Wire, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Fees {
/// TxFee is the fee for an ordinary transaction.
#[zip(json = "txFee")]
#[serde(rename = "txFee")]
pub tx_fee: Uint64,
/// CreateAssetTxFee is the fee to create an asset.
#[zip(json = "createAssetTxFee")]
#[serde(rename = "createAssetTxFee")]
pub create_asset_tx_fee: Uint64,
/// CreateChainTxFee is the fee to create a chain.
#[zip(json = "createChainTxFee")]
#[serde(rename = "createChainTxFee")]
pub create_chain_tx_fee: Uint64,
}
+37 -27
View File
@@ -11,34 +11,32 @@
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::Arc;
use std::time::Duration;
fn doors() -> (String, String) {
let app = Arc::new(info::service("luxd/1.36.178", 96369));
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 (ha, za) = (
let addrs = (
http.local_addr().unwrap().to_string(),
zap.local_addr().unwrap().to_string(),
);
drop(http);
drop(zap);
for (addr, over) in [(ha.clone(), true), (za.clone(), false)] {
let app = Arc::clone(&app);
std::thread::spawn(move || {
if over {
let _ = zip::http::listen(app, &addr);
} else {
let _ = zip::zaphttp::listen(app, &addr);
}
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),
);
});
}
for _ in 0..200 {
if TcpStream::connect(&ha).is_ok() && TcpStream::connect(&za).is_ok() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
(ha, za)
});
addrs
}
/// over_http asks one question in text and answers with the body.
@@ -56,25 +54,24 @@ fn over_http(addr: &str, target: &str) -> (u16, String) {
/// 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::zaphttp::ask("GET", target, &[]);
c.write_all(&(frame.len() as u32).to_le_bytes()).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_le_bytes(head) as usize];
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");
let flags = u16::from_le_bytes([body[6], body[7]]);
assert_eq!(
flags >> 8,
zip::zaphttp::FRAME_RESPONSE,
zip::zap::kind(&body),
Some(zip::zap::FRAME_RESPONSE),
"not a response frame"
);
let r = zip::wire::Response::wrap(&body).unwrap();
let r = zip::zap::Response::wrap(&body).unwrap();
(r.status(), String::from_utf8(r.body().to_vec()).unwrap())
}
@@ -157,3 +154,16 @@ 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`"
);
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "stream"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
zip = { path = "../../zip" }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "time"] }
[dev-dependencies]
bytes = "1"
http-body-util = "0.1"
hyper = { version = "1.12", features = ["client", "http1", "http2"] }
hyper-util = { version = "0.1.21", features = ["tokio"] }
+32
View File
@@ -0,0 +1,32 @@
# The build, in the order the facts become available.
#
# The macro compiles what it read into the crate, and the binary prints it. So
# the manifest describes THIS build — there is no file beside the source to fall
# out of step, and no way to project a service that no longer exists.
#
# It is two steps and not a build script because the description exists only
# after a compile. What zipc writes is checked in beside the source, the way a
# generated file is anywhere else, so a fresh clone builds without either tool.
#
# make gen compile, project, and compile again against the projections
# make run serve on the default addresses
# make check the doors answer, and the wire is ZAP
# gen names a directory as well as a step, and the step is the one meant.
.PHONY: gen run check
# zipc is this checkout's, so the documents are projected by the projector
# the manifest was written for.
ZIPC ?= go run ../../../cmd/zipc
ROOT := ../..
gen:
cd $(ROOT) && cargo run -q -p stream -- --manifest > examples/stream/gen/manifest.json
$(ZIPC) -out gen -package stream gen/manifest.json
cd $(ROOT) && cargo build -p stream
run:
cd $(ROOT) && cargo run -p stream
check:
cd $(ROOT) && cargo test -p stream
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The documents this service publishes, if they have been projected yet.
//!
//! They are checked in, so a clone builds with them in hand. But they are made
//! FROM this crate — zipc reads the manifest the compiled binary prints — so
//! the first build of a new service necessarily happens before they exist. This
//! says so instead of failing: no documents yet, and `make gen` makes them.
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=gen");
let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
let mut body = String::new();
for (name, file) in [("OPENAPI", "gen/openapi.json"), ("MCP", "gen/mcp.json")] {
let text = std::fs::read_to_string(file).unwrap_or_default();
body.push_str(&format!(
"pub const {name}: &str = {:?};\n",
if text.is_empty() { "" } else { text.as_str() }
));
}
std::fs::write(out.join("documents.rs"), body).expect("write documents.rs");
}
+66
View File
@@ -0,0 +1,66 @@
[
{
"Service": "count",
"Name": "create",
"OperationID": "post_count",
"Summary": "Count streams n ticks as server-sent events, then [DONE], and reports how many it sent in the x-sent trailer.",
"Description": "Count streams n ticks as server-sent events, then [DONE], and reports how\nmany it sent in the x-sent trailer.",
"Method": "POST",
"Path": "/v1/count",
"Args": [],
"Flags": [
{
"Name": "n",
"Field": "n",
"Type": "integer",
"Help": "N is how many ticks to send.",
"Required": true
},
{
"Name": "every-ms",
"Field": "everyMs",
"Type": "integer",
"Help": "EveryMs is the pause before each tick, in milliseconds.",
"Required": false
}
],
"Example": {
"n": 3,
"everyMs": 100
}
},
{
"Service": "echo",
"Name": "create",
"OperationID": "post_echo",
"Summary": "Echo answers the text it was sent.",
"Description": "Echo answers the text it was sent.",
"Method": "POST",
"Path": "/v1/echo",
"Args": [],
"Flags": [
{
"Name": "text",
"Field": "text",
"Type": "string",
"Help": "Text is the line.",
"Required": true
}
],
"Example": {
"text": "hi"
}
},
{
"Service": "relay",
"Name": "create",
"OperationID": "post_relay",
"Summary": "Relay answers the body it was sent, each chunk as it arrives, under the content type it was sent with.",
"Description": "Relay answers the body it was sent, each chunk as it arrives, under the\ncontent type it was sent with.",
"Method": "POST",
"Path": "/v1/relay",
"Args": [],
"Flags": null,
"Example": null
}
]
+1
View File
@@ -0,0 +1 @@
{"manifest":1,"app":"stream","title":"Streaming","version":"1.0.0","description":"One JSON op, one event stream and one byte relay.","ops":[{"method":"POST","path":"/v1/echo","description":"Echo answers the text it was sent.","pkg":"stream","in":"Text","out":"Text","example":{"text": "hi"},"response":{"text": "hi"}},{"method":"POST","path":"/v1/count","description":"Count streams n ticks as server-sent events, then [DONE], and reports how\nmany it sent in the x-sent trailer.","pkg":"stream","in":"Count","example":{"n": 3, "everyMs": 100},"stream":"sse"},{"method":"POST","path":"/v1/relay","description":"Relay answers the body it was sent, each chunk as it arrives, under the\ncontent type it was sent with.","pkg":"stream","stream":"bytes","raw":true}],"types":[{"id":"Count","name":"Count","kind":"struct","spell":"Count","doc":"Count asks for a stream of ticks.","fields":[{"name":"n","json":"n","required":true,"doc":"N is how many ticks to send.","type":{"prim":"u32"},"spell":"u32"},{"name":"every_ms","json":"everyMs","doc":"EveryMs is the pause before each tick, in milliseconds.","type":{"prim":"u64"},"spell":"u64"}]},{"id":"Text","name":"Text","kind":"struct","spell":"Text","doc":"Text is a line of text.","fields":[{"name":"text","json":"text","required":true,"doc":"Text is the line.","type":{"prim":"string"},"spell":"String"}]}]}
+51
View File
@@ -0,0 +1,51 @@
[
{
"annotations": {},
"description": "Count streams n ticks as server-sent events, then [DONE], and reports how\nmany it sent in the x-sent trailer.",
"inputSchema": {
"properties": {
"everyMs": {
"description": "EveryMs is the pause before each tick, in milliseconds.",
"format": "uint64",
"type": "integer"
},
"n": {
"description": "N is how many ticks to send.",
"format": "uint32",
"type": "integer"
}
},
"required": [
"n"
],
"type": "object"
},
"name": "post_count"
},
{
"annotations": {},
"description": "Echo answers the text it was sent.",
"inputSchema": {
"properties": {
"text": {
"description": "Text is the line.",
"type": "string"
}
},
"required": [
"text"
],
"type": "object"
},
"name": "post_echo"
},
{
"annotations": {},
"description": "Relay answers the body it was sent, each chunk as it arrives, under the\ncontent type it was sent with.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "post_relay"
}
]
+205
View File
@@ -0,0 +1,205 @@
{
"components": {
"schemas": {
"Count": {
"properties": {
"everyMs": {
"description": "EveryMs is the pause before each tick, in milliseconds.",
"format": "uint64",
"type": "integer"
},
"n": {
"description": "N is how many ticks to send.",
"format": "uint32",
"type": "integer"
}
},
"required": [
"n"
],
"type": "object"
},
"Text": {
"properties": {
"text": {
"description": "Text is the line.",
"type": "string"
}
},
"required": [
"text"
],
"type": "object"
},
"problem-details": {
"description": "An RFC 9457 problem document.",
"properties": {
"code": {
"description": "A stable token a client can branch on, when the refusal names one.",
"type": "string"
},
"detail": {
"description": "What was refused, for a person to read.",
"type": "string"
},
"status": {
"description": "The HTTP status.",
"format": "int64",
"type": "integer"
},
"title": {
"description": "The status's reason phrase.",
"type": "string"
},
"type": {
"description": "about:blank",
"type": "string"
}
},
"required": [
"type",
"status",
"detail"
],
"type": "object"
}
}
},
"info": {
"description": "One JSON op, one event stream and one byte relay.",
"title": "Streaming",
"version": "1.0.0"
},
"openapi": "3.1.0",
"paths": {
"/v1/count": {
"post": {
"description": "Count streams n ticks as server-sent events, then [DONE], and reports how\nmany it sent in the x-sent trailer.",
"operationId": "post_count",
"requestBody": {
"content": {
"application/json": {
"example": {
"n": 3,
"everyMs": 100
},
"schema": {
"$ref": "#/components/schemas/Count"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
},
"description": "ok"
},
"default": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/problem-details"
}
}
},
"description": "refused"
}
},
"summary": "Count streams n ticks as server-sent events, then [DONE], and reports how many it sent in the x-sent trailer."
}
},
"/v1/echo": {
"post": {
"description": "Echo answers the text it was sent.",
"operationId": "post_echo",
"requestBody": {
"content": {
"application/json": {
"example": {
"text": "hi"
},
"schema": {
"$ref": "#/components/schemas/Text"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"text": "hi"
},
"schema": {
"$ref": "#/components/schemas/Text"
}
}
},
"description": "ok"
},
"default": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/problem-details"
}
}
},
"description": "refused"
}
},
"summary": "Echo answers the text it was sent."
}
},
"/v1/relay": {
"post": {
"description": "Relay answers the body it was sent, each chunk as it arrives, under the\ncontent type it was sent with.",
"operationId": "post_relay",
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "ok"
},
"default": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/problem-details"
}
}
},
"description": "refused"
}
},
"summary": "Relay answers the body it was sent, each chunk as it arrives, under the content type it was sent with."
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
# Generated from typed ops. Do not edit.
# Every struct and method below is derived from one op's In and Out, and
# every offset from the layout the op-call plane encodes against.
package stream
struct Count {
n u32 @0
every_ms u64 @8
}
struct Text {
text_ text @0
}
interface stream {
# Count streams n ticks as server-sent events, then [DONE], and reports how
# many it sent in the x-sent trailer.
post_count(req: Count)
# Echo answers the text it was sent.
post_echo(req: Text) returns (rep: Text)
# Relay answers the body it was sent, each chunk as it arrives, under the
# content type it was sent with.
post_relay()
}
+136
View File
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! A streaming service: one JSON op, one event stream, one byte relay — the
//! three shapes a chat-completions router is made of.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::stream;
use serde::{Deserialize, Serialize};
use zip::{Body, Cx, Error, Event, Sse, Wire};
// The projections, as zipc wrote them from what this crate declared.
include!(concat!(env!("OUT_DIR"), "/documents.rs"));
/// Text is a line of text.
#[derive(Wire, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Text {
/// Text is the line.
#[zip(required)]
pub text: String,
}
/// Count asks for a stream of ticks.
#[derive(Wire, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Count {
/// N is how many ticks to send.
#[zip(required)]
pub n: u32,
/// EveryMs is the pause before each tick, in milliseconds.
pub every_ms: u64,
}
/// Tick is one event of a count.
#[derive(Serialize, Deserialize)]
pub struct Tick {
/// I is the tick's number, from 1.
pub i: u32,
}
/// Chat is the service. `open` is how many streams are being produced right
/// now: a gauge a deployment exports, and what shows a stream was dropped.
#[derive(Default)]
pub struct Chat {
pub open: Arc<AtomicUsize>,
}
#[zip::ops(
app = "stream",
title = "Streaming",
version = "1.0.0",
description = "One JSON op, one event stream and one byte relay."
)]
impl Chat {
/// Echo answers the text it was sent.
///
/// Example: {"text": "hi"}
/// Response: {"text": "hi"}
#[post("/v1/echo")]
async fn echo(&self, arg: &Text) -> Result<Text, Error> {
Ok(Text {
text: arg.text.clone(),
})
}
/// Count streams n ticks as server-sent events, then [DONE], and reports how
/// many it sent in the x-sent trailer.
///
/// Example: {"n": 3, "everyMs": 100}
#[post("/v1/count")]
async fn count(&self, cx: &Cx, arg: &Count) -> Result<Sse, Error> {
let sent = cx.trailer("x-sent")?;
let open = Open::new(&self.open);
let (n, every) = (arg.n, Duration::from_millis(arg.every_ms));
// The producer's state is the stream's: dropping the stream — which is
// what the door does when the client leaves — drops `open` with it.
let ticks = stream::unfold((1, open), move |(i, open)| {
let sent = sent.clone();
async move {
if i > n + 1 {
return None;
}
if i == n + 1 {
sent.set(&n.to_string()).ok()?;
return Some((Ok::<_, Error>(Event::data("[DONE]")), (i + 1, open)));
}
tokio::time::sleep(every).await;
Some((Event::json(&Tick { i }), (i + 1, open)))
}
});
Ok(Sse::new(ticks).keep(Duration::from_secs(15)))
}
/// Relay answers the body it was sent, each chunk as it arrives, under the
/// content type it was sent with.
#[post("/v1/relay")]
async fn relay(&self, cx: &Cx, body: Body) -> Result<Body, Error> {
if let Some(kind) = cx.header("content-type") {
cx.set_header("content-type", kind)?;
}
Ok(body)
}
}
/// Open counts one stream for as long as it lives.
struct Open(Arc<AtomicUsize>);
impl Open {
fn new(gauge: &Arc<AtomicUsize>) -> Open {
gauge.fetch_add(1, Ordering::SeqCst);
Open(Arc::clone(gauge))
}
}
impl Drop for Open {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
/// service is the app, with its documents on it.
pub fn service(chat: Chat) -> zip::App {
let mut app = chat.ops();
if !OPENAPI.is_empty() {
app.serve("/openapi.json", "application/json", OPENAPI);
}
if !MCP.is_empty() {
app.serve("/mcp.json", "application/json", MCP);
}
app
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The streaming service, serving REST and ZAP from one registry.
//!
//! ```text
//! stream --http 127.0.0.1:8000 --zap 127.0.0.1:9653
//! ```
#[tokio::main]
async fn main() {
let mut http = Some("127.0.0.1:8000".to_string());
let mut zap = None;
let args: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--http" => {
http = args.get(i + 1).cloned();
i += 2;
}
"--zap" => {
zap = args.get(i + 1).cloned();
i += 2;
}
"--manifest" => {
println!("{}", stream::Chat::manifest());
return;
}
other => {
eprintln!("stream: unknown argument {other}");
std::process::exit(2);
}
}
}
let app = stream::service(stream::Chat::default());
for (method, path) in app.routes() {
println!(" {method} {path}");
}
let grace = std::time::Duration::from_secs(25);
if let Err(e) = zip::listen(app, http.as_deref(), zap.as_deref(), zip::signal(), grace).await {
eprintln!("stream: {e}");
std::process::exit(1);
}
}
+576
View File
@@ -0,0 +1,576 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The service on a real port, asked over real sockets.
//!
//! HTTP/1.1 is spoken by hand here, on a bare TCP stream, so the test sees
//! what the wire carries — the chunks, when each arrived, the trailers after
//! the last — and can hang up mid-answer exactly when it means to.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
struct Server {
addr: String,
open: Arc<std::sync::atomic::AtomicUsize>,
stop: Option<oneshot::Sender<()>>,
done: JoinHandle<std::io::Result<()>>,
}
#[derive(Clone, Copy)]
enum Door {
Http,
Zap,
}
async fn start(door: Door) -> Server {
start_with(door, Duration::from_secs(5)).await
}
async fn start_with(door: Door, grace: Duration) -> Server {
let chat = stream::Chat::default();
let open = Arc::clone(&chat.open);
let app = Arc::new(stream::service(chat));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (stop, stopped) = oneshot::channel::<()>();
let shutdown = async move {
let _ = stopped.await;
};
let done = match door {
Door::Http => tokio::spawn(zip::http::serve(app, listener, shutdown, grace)),
Door::Zap => tokio::spawn(zip::zaphttp::serve(app, listener, shutdown, grace)),
};
Server {
addr,
open,
stop: Some(stop),
done,
}
}
/// Wire is one HTTP/1.1 connection, read by hand.
struct Wire {
conn: TcpStream,
buf: Vec<u8>,
}
impl Wire {
async fn dial(addr: &str) -> Wire {
Wire {
conn: TcpStream::connect(addr).await.unwrap(),
buf: Vec::new(),
}
}
async fn send(&mut self, text: &str) {
self.conn.write_all(text.as_bytes()).await.unwrap();
}
async fn post(&mut self, path: &str, body: &str, extra: &str) {
let req = format!(
"POST {path} HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra}\r\n{body}",
body.len()
);
self.send(&req).await;
}
async fn fill(&mut self) {
let mut chunk = [0u8; 4096];
let n = tokio::time::timeout(Duration::from_secs(10), self.conn.read(&mut chunk))
.await
.expect("the server said nothing for 10 s")
.unwrap();
assert!(n > 0, "the server closed the connection");
self.buf.extend_from_slice(&chunk[..n]);
}
async fn line(&mut self) -> String {
loop {
if let Some(i) = self.buf.windows(2).position(|w| w == b"\r\n") {
let line = String::from_utf8(self.buf[..i].to_vec()).unwrap();
self.buf.drain(..i + 2);
return line;
}
self.fill().await;
}
}
async fn exact(&mut self, n: usize) -> Vec<u8> {
while self.buf.len() < n {
self.fill().await;
}
self.buf.drain(..n).collect()
}
/// head reads a status line and headers, names lowercased.
async fn head(&mut self) -> (u16, Vec<(String, String)>) {
let status = self.line().await;
let code = status.split_whitespace().nth(1).unwrap().parse().unwrap();
let mut headers = Vec::new();
loop {
let l = self.line().await;
if l.is_empty() {
return (code, headers);
}
let (k, v) = l.split_once(':').unwrap();
headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
}
}
/// chunk reads one chunk of a chunked body: Some(bytes), or None at the
/// last-chunk, after which the trailer lines are read.
async fn chunk(&mut self) -> Option<Vec<u8>> {
let size = usize::from_str_radix(self.line().await.trim(), 16).unwrap();
if size == 0 {
return None;
}
let data = self.exact(size).await;
assert_eq!(self.line().await, "", "a chunk ends in CRLF");
Some(data)
}
async fn trailers(&mut self) -> Vec<String> {
let mut out = Vec::new();
loop {
let l = self.line().await;
if l.is_empty() {
return out;
}
out.push(l.to_ascii_lowercase());
}
}
}
fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> {
h.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
}
/// (a) A JSON op answers, and the connection stays open for the next request.
#[tokio::test(flavor = "multi_thread")]
async fn a_json_op_round_trips_on_one_connection() {
let s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
for _ in 0..3 {
w.post("/v1/echo", r#"{"text":"hi"}"#, "").await;
let (status, h) = w.head().await;
assert_eq!(status, 200);
assert_eq!(header(&h, "content-type"), Some("application/json"));
let n: usize = header(&h, "content-length").unwrap().parse().unwrap();
assert_eq!(w.exact(n).await, br#"{"text":"hi"}"#);
}
// A field the op cannot run without is refused before the handler runs.
w.post("/v1/echo", "{}", "").await;
let (status, h) = w.head().await;
assert_eq!(status, 400);
assert_eq!(header(&h, "content-type"), Some("application/problem+json"));
let n: usize = header(&h, "content-length").unwrap().parse().unwrap();
let body = String::from_utf8(w.exact(n).await).unwrap();
assert!(body.contains(r#"field \"text\" is required"#), "{body}");
}
/// events reads an event stream until its last chunk, stamping each event
/// with when its blank line arrived.
async fn events(w: &mut Wire, since: Instant) -> Vec<(String, Duration)> {
let mut out = Vec::new();
let mut pending = Vec::new();
while let Some(chunk) = w.chunk().await {
pending.extend_from_slice(&chunk);
while let Some(i) = pending.windows(2).position(|x| x == b"\n\n") {
let event = String::from_utf8(pending[..i].to_vec()).unwrap();
pending.drain(..i + 2);
out.push((event, since.elapsed()));
}
}
assert!(pending.is_empty(), "the stream ended mid-event");
out
}
/// (b) Each event is written when it is produced. Five ticks 200 ms apart
/// arrive 200 ms apart; a server that buffered them would deliver five at once.
#[tokio::test(flavor = "multi_thread")]
async fn an_event_arrives_when_it_is_produced() {
let s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
let every = Duration::from_millis(200);
let since = Instant::now();
w.post("/v1/count", r#"{"n":5,"everyMs":200}"#, "").await;
let (status, h) = w.head().await;
assert_eq!(status, 200);
assert_eq!(header(&h, "content-type"), Some("text/event-stream"));
assert_eq!(header(&h, "cache-control"), Some("no-cache"));
assert_eq!(header(&h, "transfer-encoding"), Some("chunked"));
let got = events(&mut w, since).await;
// No TE: trailers, so no trailers: hyper sends them on no other terms.
assert!(w.trailers().await.is_empty());
let texts: Vec<&str> = got.iter().map(|(e, _)| e.as_str()).collect();
assert_eq!(
texts,
vec![
r#"data: {"i":1}"#,
r#"data: {"i":2}"#,
r#"data: {"i":3}"#,
r#"data: {"i":4}"#,
r#"data: {"i":5}"#,
"data: [DONE]"
]
);
let at: Vec<u128> = got.iter().map(|(_, t)| t.as_millis()).collect();
eprintln!("arrivals (ms): {at:?}");
for pair in got[..5].windows(2) {
let gap = pair[1].1 - pair[0].1;
assert!(
gap >= every / 2,
"two ticks arrived {gap:?} apart; they were produced {every:?} apart: {at:?}"
);
}
assert!(
got[4].1 - got[0].1 >= every * 4 / 2,
"the first tick waited for the last: {at:?}"
);
// The stream ended cleanly — its last-chunk arrived — so the connection
// is still good for another request.
w.post("/v1/echo", r#"{"text":"after"}"#, "").await;
assert_eq!(w.head().await.0, 200);
}
/// (c) A client that hangs up mid-stream drops the producer: the stream's
/// state, and whatever it holds, goes with it.
#[tokio::test(flavor = "multi_thread")]
async fn a_client_leaving_drops_the_producer() {
let s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
w.post("/v1/count", r#"{"n":100000,"everyMs":10}"#, "")
.await;
assert_eq!(w.head().await.0, 200);
let first = w.chunk().await.unwrap();
assert_eq!(first, b"data: {\"i\":1}\n\n");
assert_eq!(
s.open.load(Ordering::SeqCst),
1,
"one stream is being produced"
);
let left = Instant::now();
drop(w);
while s.open.load(Ordering::SeqCst) != 0 {
assert!(
left.elapsed() < Duration::from_secs(5),
"the producer outlived its client by 5 s"
);
tokio::time::sleep(Duration::from_millis(1)).await;
}
eprintln!(
"producer dropped {:?} after the client left",
left.elapsed()
);
}
/// The same, while the producer is idle: no event is due for a minute, so
/// nothing is being written, and the hang-up alone has to be noticed.
#[tokio::test(flavor = "multi_thread")]
async fn a_client_leaving_an_idle_stream_drops_the_producer() {
let s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
w.post("/v1/count", r#"{"n":3,"everyMs":60000}"#, "").await;
assert_eq!(w.head().await.0, 200);
while s.open.load(Ordering::SeqCst) != 1 {
tokio::time::sleep(Duration::from_millis(1)).await;
}
let left = Instant::now();
drop(w);
while s.open.load(Ordering::SeqCst) != 0 {
assert!(
left.elapsed() < Duration::from_secs(5),
"an idle producer outlived its client by 5 s"
);
tokio::time::sleep(Duration::from_millis(1)).await;
}
eprintln!(
"idle producer dropped {:?} after the client left",
left.elapsed()
);
}
/// (d) The service publishes the document its declaration projects, and the
/// document says what each op answers with.
#[tokio::test(flavor = "multi_thread")]
async fn the_document_lists_the_ops() {
let s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
w.send("GET /openapi.json HTTP/1.1\r\nHost: x\r\n\r\n")
.await;
let (status, h) = w.head().await;
assert_eq!(status, 200);
let n: usize = header(&h, "content-length").unwrap().parse().unwrap();
let doc = String::from_utf8(w.exact(n).await).unwrap();
for want in [
r#""operationId": "post_echo""#,
r#""operationId": "post_count""#,
r#""operationId": "post_relay""#,
r#""text/event-stream""#,
r#""application/octet-stream""#,
] {
assert!(doc.contains(want), "the document lacks {want}");
}
}
/// The documents beside the source were projected from THIS declaration.
#[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(),
stream::Chat::manifest(),
"the declaration changed since `make gen`"
);
}
/// A trailer reaches an HTTP/1.1 client that asked for trailers, after the
/// last chunk, and its name went out in the head.
#[tokio::test(flavor = "multi_thread")]
async fn a_trailer_follows_the_stream() {
let s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
w.post("/v1/count", r#"{"n":2,"everyMs":1}"#, "TE: trailers\r\n")
.await;
let (status, h) = w.head().await;
assert_eq!(status, 200);
assert_eq!(header(&h, "trailer"), Some("x-sent"));
let got = events(&mut w, Instant::now()).await;
assert_eq!(got.len(), 3);
assert_eq!(w.trailers().await, vec!["x-sent: 2"]);
}
/// A raw op relays bytes in both directions at once: each chunk of the
/// request comes back before the next one is sent.
#[tokio::test(flavor = "multi_thread")]
async fn a_relay_answers_each_chunk_as_it_arrives() {
let s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
w.send("POST /v1/relay HTTP/1.1\r\nHost: x\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n")
.await;
w.send("5\r\nhello\r\n").await;
let (status, h) = w.head().await;
assert_eq!(status, 200);
assert_eq!(header(&h, "content-type"), Some("text/plain"));
assert_eq!(w.chunk().await.unwrap(), b"hello");
w.send("6\r\n world\r\n").await;
assert_eq!(w.chunk().await.unwrap(), b" world");
w.send("0\r\n\r\n").await;
assert_eq!(w.chunk().await, None);
assert!(w.trailers().await.is_empty());
}
/// HTTP/2 over cleartext on the same port: the stream arrives frame by frame
/// and its trailer after the last.
#[tokio::test(flavor = "multi_thread")]
async fn http2_streams_and_carries_the_trailer() {
use http_body_util::BodyExt;
let s = start(Door::Http).await;
let tcp = TcpStream::connect(&s.addr).await.unwrap();
let (mut send, conn) = hyper::client::conn::http2::handshake(
hyper_util::rt::TokioExecutor::new(),
hyper_util::rt::TokioIo::new(tcp),
)
.await
.unwrap();
tokio::spawn(conn);
let req = hyper::Request::post(format!("http://{}/v1/count", s.addr))
.header("content-type", "application/json")
.body(http_body_util::Full::new(bytes::Bytes::from_static(
br#"{"n":3,"everyMs":150}"#,
)))
.unwrap();
let res = send.send_request(req).await.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(res.version(), hyper::Version::HTTP_2);
let mut body = res.into_body();
let since = Instant::now();
let mut data = Vec::new();
let mut stamps = Vec::new();
let mut trailers = None;
while let Some(frame) = body.frame().await {
let frame = frame.unwrap();
if let Some(d) = frame.data_ref() {
data.extend_from_slice(d);
stamps.push(since.elapsed());
} else if let Ok(t) = frame.into_trailers() {
trailers = Some(t);
}
}
assert_eq!(
String::from_utf8(data).unwrap(),
"data: {\"i\":1}\n\ndata: {\"i\":2}\n\ndata: {\"i\":3}\n\ndata: [DONE]\n\n"
);
assert!(
stamps[1] - stamps[0] >= Duration::from_millis(75),
"frames {stamps:?}"
);
let trailers = trailers.expect("no trailers");
assert_eq!(trailers.get("x-sent").unwrap(), "3");
}
/// Shutdown stops new connections and lets the stream in flight finish.
#[tokio::test(flavor = "multi_thread")]
async fn shutdown_drains_what_is_in_flight() {
let mut s = start(Door::Http).await;
let mut w = Wire::dial(&s.addr).await;
w.post("/v1/count", r#"{"n":4,"everyMs":150}"#, "").await;
assert_eq!(w.head().await.0, 200);
assert!(w.chunk().await.is_some());
s.stop.take().unwrap().send(()).unwrap();
let mut refused = false;
for _ in 0..100 {
if TcpStream::connect(&s.addr).await.is_err() {
refused = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(refused, "a new connection was accepted after shutdown");
let rest = events(&mut w, Instant::now()).await;
assert_eq!(rest.last().unwrap().0, "data: [DONE]", "the stream was cut");
tokio::time::timeout(Duration::from_secs(5), s.done)
.await
.expect("serve did not return once drained")
.unwrap()
.unwrap();
}
/// The ZAP door streams the same op: a head frame, a data frame as each event
/// is produced, an end frame holding the trailer.
#[tokio::test(flavor = "multi_thread")]
async fn the_zap_door_streams_frame_by_frame() {
let s = start(Door::Zap).await;
let mut conn = TcpStream::connect(&s.addr).await.unwrap();
let headers = zip::zap::block([(&b"Content-Type"[..], &b"application/json"[..])]);
let frame = zip::zap::request("POST", "/v1/count", &headers, br#"{"n":3,"everyMs":150}"#);
conn.write_all(&(frame.len() as u32).to_be_bytes())
.await
.unwrap();
conn.write_all(&frame).await.unwrap();
let since = Instant::now();
let head = zip::zaphttp::read_frame(&mut conn).await.unwrap().unwrap();
assert_eq!(zip::zap::kind(&head), Some(zip::zap::FRAME_HEAD));
let r = zip::zap::Response::wrap(&head).unwrap();
assert_eq!(r.status(), 200);
let names: Vec<_> = zip::zap::pairs(r.headers())
.into_iter()
.map(|(k, v)| {
(
String::from_utf8_lossy(k).into_owned(),
String::from_utf8_lossy(v).into_owned(),
)
})
.collect();
assert!(
names.contains(&("content-type".into(), "text/event-stream".into())),
"{names:?}"
);
assert!(
!names.iter().any(|(k, _)| k == "trailer"),
"Trailer is the frame's slot, not a header"
);
let mut chunks = Vec::new();
loop {
let f = zip::zaphttp::read_frame(&mut conn).await.unwrap().unwrap();
match zip::zap::kind(&f) {
Some(zip::zap::FRAME_DATA) => {
let c = zip::zap::chunk(&f).unwrap().to_vec();
chunks.push((String::from_utf8(c).unwrap(), since.elapsed()));
}
Some(zip::zap::FRAME_END) => {
let t = zip::zap::pairs(zip::zap::chunk(&f).unwrap());
assert_eq!(t, vec![(&b"x-sent"[..], &b"3"[..])]);
break;
}
other => panic!("frame type {other:?} inside a stream"),
}
}
assert_eq!(chunks.len(), 4, "{chunks:?}");
assert_eq!(chunks[0].0, "data: {\"i\":1}\n\n");
assert_eq!(chunks[3].0, "data: [DONE]\n\n");
assert!(
chunks[1].1 - chunks[0].1 >= Duration::from_millis(75),
"{chunks:?}"
);
// One exchange done, the connection answers the next.
let frame = zip::zap::request("POST", "/v1/echo", &headers, br#"{"text":"zap"}"#);
conn.write_all(&(frame.len() as u32).to_be_bytes())
.await
.unwrap();
conn.write_all(&frame).await.unwrap();
let f = zip::zaphttp::read_frame(&mut conn).await.unwrap().unwrap();
let r = zip::zap::Response::wrap(&f).unwrap();
assert_eq!((r.status(), r.body()), (200, &br#"{"text":"zap"}"#[..]));
}
/// A stream still going when the grace runs out is cut, and its producer
/// dropped: shutdown is bounded by the grace, not by the longest answer.
#[tokio::test(flavor = "multi_thread")]
async fn shutdown_ends_what_outlives_the_grace() {
for door in [Door::Http, Door::Zap] {
let mut s = start_with(door, Duration::from_millis(300)).await;
let mut conn = TcpStream::connect(&s.addr).await.unwrap();
let body = br#"{"n":100000,"everyMs":10}"#;
match door {
Door::Http => {
let head = format!(
"POST /v1/count HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n",
body.len()
);
conn.write_all(head.as_bytes()).await.unwrap();
conn.write_all(body).await.unwrap();
}
Door::Zap => {
let frame = zip::zap::request("POST", "/v1/count", &[], body);
conn.write_all(&(frame.len() as u32).to_be_bytes())
.await
.unwrap();
conn.write_all(&frame).await.unwrap();
}
}
while s.open.load(Ordering::SeqCst) != 1 {
tokio::time::sleep(Duration::from_millis(1)).await;
}
let stopped = Instant::now();
s.stop.take().unwrap().send(()).unwrap();
tokio::time::timeout(Duration::from_secs(5), s.done)
.await
.expect("serve outlived its grace")
.unwrap()
.unwrap();
let took = stopped.elapsed();
assert!(
took >= Duration::from_millis(300),
"serve returned in {took:?}, inside its grace"
);
while s.open.load(Ordering::SeqCst) != 0 {
assert!(
stopped.elapsed() < Duration::from_secs(5),
"the producer outlived the grace"
);
tokio::time::sleep(Duration::from_millis(1)).await;
}
// The client reads to the end of what it was sent and finds the
// connection closed, the answer cut.
let mut rest = Vec::new();
let _ = tokio::time::timeout(Duration::from_secs(5), conn.read_to_end(&mut rest)).await;
}
}
+16 -10
View File
@@ -4,16 +4,19 @@
//! `#[zip(...)]` — what a declaration says about itself beyond its type.
//!
//! One attribute, one vocabulary, on a type and on a field alike. It is the
//! counterpart of a Go struct tag and holds the same four facts: the name the
//! body carries a field under, the name a URL does, the header it reads, and
//! whether the handler refuses to run without it.
//! One attribute, one vocabulary, on a type and on a field alike. With serde's
//! own attributes it is the counterpart of a Go struct tag: serde says the name
//! the body carries a field under (`#[serde(rename)]`) and whether it carries
//! it at all (`#[serde(skip)]`); this says the rest — the name a URL carries it
//! under, the header it reads, and whether the handler refuses to run without
//! it.
use syn::{Attribute, Expr, Lit, Meta, Token};
#[derive(Default)]
pub struct Attrs {
/// json is the name the body carries this under.
/// json is a field's wire name given here, which is refused: the name is
/// serde's, and two places to write it are two names.
pub json: Option<String>,
/// url is the name a URL carries it under; "-" opts out.
pub url: Option<String>,
@@ -21,8 +24,6 @@ pub struct Attrs {
pub header: Option<String>,
/// required says the handler refuses to run without it.
pub required: bool,
/// skip keeps it off the wire entirely.
pub skip: bool,
/// text says this type is carried as one word, so a URL can hold it.
pub text: bool,
/// json_schema is the shape a type states for itself, raw.
@@ -42,7 +43,12 @@ impl Attrs {
for item in items {
match &item {
Meta::Path(p) if p.is_ident("required") => out.required = true,
Meta::Path(p) if p.is_ident("skip") => out.skip = true,
Meta::Path(p) if p.is_ident("skip") => {
return Err(syn::Error::new_spanned(
p,
"zip: whether a field rides the wire is serde's — #[serde(skip)]",
))
}
Meta::Path(p) if p.is_ident("text") => out.text = true,
Meta::NameValue(nv) => {
let v = literal(&nv.value)?;
@@ -63,14 +69,14 @@ impl Attrs {
} else {
return Err(syn::Error::new_spanned(
&nv.path,
"zip: known keys are json, url, header, required, skip, text",
"zip: known keys are json, url, header, required, text",
));
}
}
other => {
return Err(syn::Error::new_spanned(
other,
"zip: known keys are json, url, header, required, skip, text",
"zip: known keys are json, url, header, required, text",
))
}
}
+1
View File
@@ -14,6 +14,7 @@ mod attr;
mod doc;
mod frag;
mod ops;
mod serde;
mod ty;
mod wire;
+156 -46
View File
@@ -53,7 +53,7 @@ pub fn expand(args: TokenStream, mut block: ItemImpl) -> Result<TokenStream, syn
let stated = fragment(&about, &ops);
let reaches: Vec<TokenStream> = ops
.iter()
.flat_map(|o| [o.input.clone(), o.output.clone()])
.flat_map(|o| [o.input.described(), o.output.described()])
.flatten()
.map(|t| quote!(<#t as ::zip::Wire>::reach(&mut types);))
.collect();
@@ -161,80 +161,165 @@ struct Op {
verb: String,
path: String,
call: syn::Ident,
input: Option<Type>,
output: Option<Type>,
/// args is the handler's parameters after `&self`, in the order declared.
args: Vec<Arg>,
input: In,
output: Answer,
doc: doc::Doc,
}
/// Arg is one parameter a handler takes.
enum Arg {
/// Cx is `&zip::Cx`.
Cx,
/// Input is the op's input, by reference or by value.
Input { by_ref: bool },
}
/// In is what an op reads its input from.
enum In {
None,
/// Value is a described type, bound from the body, the URL and headers.
Value(Box<Type>),
/// Body is the request body as it arrives, bound to nothing.
Body,
}
/// Answer is what an op answers with.
enum Answer {
None,
/// Value is one described type, as JSON.
Value(Box<Type>),
Sse,
Body,
}
impl In {
fn described(&self) -> Option<Type> {
match self {
In::Value(t) => Some((**t).clone()),
_ => None,
}
}
}
impl Answer {
fn described(&self) -> Option<Type> {
match self {
Answer::Value(t) => Some((**t).clone()),
_ => None,
}
}
}
impl Op {
fn read(verb: String, path: String, f: &syn::ImplItemFn) -> Result<Self, syn::Error> {
let mut input = None;
if f.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
f.sig.fn_token,
"zip::ops: an op is an async fn; it runs on the server's tasks",
));
}
let mut args = Vec::new();
let mut input = In::None;
for arg in f.sig.inputs.iter() {
match arg {
FnArg::Receiver(_) => {}
FnArg::Typed(t) => {
if input.is_some() {
let FnArg::Typed(t) = arg else { continue };
let by_ref = matches!(&*t.ty, Type::Reference(_));
let ty = strip(&t.ty);
match last(&ty).as_deref() {
Some("Cx") => {
if !by_ref {
return Err(syn::Error::new_spanned(
arg,
"zip::ops: an op takes one input; a request is one value",
"zip::ops: the exchange is borrowed: `cx: &zip::Cx`",
));
}
input = Some(strip(&t.ty));
args.push(Arg::Cx);
continue;
}
Some("Body") => {
if by_ref {
return Err(syn::Error::new_spanned(
arg,
"zip::ops: a body is read as it arrives, so it is taken by value: `body: zip::Body`",
));
}
if !matches!(input, In::None) {
return Err(one_input(arg));
}
input = In::Body;
}
_ => {
if !matches!(input, In::None) {
return Err(one_input(arg));
}
input = In::Value(Box::new(ty));
}
}
args.push(Arg::Input { by_ref });
}
let output = match &f.sig.output {
ReturnType::Default => None,
ReturnType::Type(_, t) => answer(t),
ReturnType::Type(_, t) if last(t).as_deref() == Some("Result") => match answer(t) {
None => Answer::None,
Some(t) => match last(&t).as_deref() {
Some("Sse") => Answer::Sse,
Some("Body") => Answer::Body,
_ => Answer::Value(Box::new(t)),
},
},
other => {
return Err(syn::Error::new_spanned(
other,
"zip::ops: an op answers Result<T, zip::Error>; a refusal is part of every op's contract",
))
}
};
Ok(Op {
verb,
path,
call: f.sig.ident.clone(),
args,
input,
output,
doc: doc::read(&f.attrs),
})
}
/// register is the line that puts this op on the app: decode the input the
/// way the document says it arrives, run the handler, write the answer.
/// register is the line that puts this op on the app: read the input the
/// way the document says it arrives, run the handler, answer.
fn register(&self) -> TokenStream {
let verb = self.verb.to_uppercase();
let path = &self.path;
let call = &self.call;
let run = match (&self.input, &self.output) {
(Some(i), Some(_)) => quote! {
let arg = ::zip::bind::<#i>(input)?;
let answer = holder.#call(&arg)?;
let mut out = ::std::string::String::new();
::zip::Wire::write_json(&answer, &mut out);
Ok(::std::option::Option::Some(out))
},
(Some(i), None) => quote! {
let arg = ::zip::bind::<#i>(input)?;
holder.#call(&arg)?;
Ok(::std::option::Option::None)
},
(None, Some(_)) => quote! {
let answer = holder.#call()?;
let mut out = ::std::string::String::new();
::zip::Wire::write_json(&answer, &mut out);
Ok(::std::option::Option::Some(out))
},
(None, None) => quote! {
holder.#call()?;
Ok(::std::option::Option::None)
},
let take = match &self.input {
In::None => quote! { ::std::mem::drop(body); },
In::Body => quote! { let arg = body; },
In::Value(i) => quote! { let arg = ::zip::bind::<#i>(&cx, body).await?; },
};
let describe = match &self.input {
Some(i) => quote!(::std::option::Option::Some(<#i as ::zip::Wire>::describe())),
None => quote!(::std::option::Option::None),
let pass = self.args.iter().map(|a| match a {
Arg::Cx => quote!(&cx),
Arg::Input { by_ref: true } => quote!(&arg),
Arg::Input { by_ref: false } => quote!(arg),
});
let run = quote!(holder.#call(#(#pass),*).await?);
let reply = match &self.output {
Answer::None => quote! { #run; Ok(::zip::Reply::Empty) },
Answer::Value(_) => quote! { let answer = #run; ::zip::Reply::json(&answer) },
Answer::Sse | Answer::Body => {
quote! { let answer = #run; Ok(::zip::Reply::from(answer)) }
}
};
quote! {
app.op(#verb, #path, #describe, {
app.op(#verb, #path, {
let holder = ::std::sync::Arc::clone(&holder);
::std::boxed::Box::new(move |input: &::zip::Input| { #run })
move |cx: ::zip::Cx, body: ::zip::Body| {
let holder = ::std::sync::Arc::clone(&holder);
async move {
let _ = &cx;
#take
#reply
}
}
});
}
}
@@ -245,11 +330,11 @@ impl Op {
.text("path", &self.path)
.text("description", &self.doc.text)
.text("pkg", &std::env::var("CARGO_PKG_NAME").unwrap_or_default());
if let Some(t) = &self.input {
o.text("in", &name_of(t));
if let Some(t) = self.input.described() {
o.text("in", &name_of(&t));
}
if let Some(t) = &self.output {
o.text("out", &name_of(t));
if let Some(t) = self.output.described() {
o.text("out", &name_of(&t));
}
if !self.doc.example.is_empty() {
o.raw("example", &self.doc.example);
@@ -257,10 +342,35 @@ impl Op {
if !self.doc.response.is_empty() {
o.raw("response", &self.doc.response);
}
match self.output {
Answer::Sse => {
o.text("stream", "sse");
}
Answer::Body => {
o.text("stream", "bytes");
}
_ => {}
}
o.flag("raw", matches!(self.input, In::Body));
o.finish()
}
}
fn one_input(arg: &FnArg) -> syn::Error {
syn::Error::new_spanned(
arg,
"zip::ops: an op takes one input; a request is one value",
)
}
/// last is the last segment of a type's path: what the macro can see of it.
fn last(t: &Type) -> Option<String> {
match t {
Type::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
}
}
/// fragment is the ops half of the manifest — the object's MEMBERS, without its
/// braces, because [zip::manifest] closes it around the types half.
fn fragment(about: &About, ops: &[Op]) -> String {
+196
View File
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! What serde was told about a type's names.
//!
//! The derive reads `#[serde(...)]` for the four things that decide what a
//! field is called on the wire — `rename`, `rename_all`, `skip` and `flatten` —
//! plus `with`, which is how a byte field says it is base64. Everything else
//! serde accepts is passed over: it changes how a value is read, not what it is
//! called.
use proc_macro2::TokenStream;
use syn::meta::ParseNestedMeta;
use syn::{Attribute, LitStr, Token};
/// Container is what a struct told serde.
#[derive(Default)]
pub struct Container {
pub rename_all: Option<String>,
pub transparent: bool,
}
impl Container {
pub fn read(attrs: &[Attribute]) -> Result<Self, syn::Error> {
let mut out = Container::default();
for a in serde(attrs) {
a.parse_nested_meta(|meta| {
if meta.path.is_ident("rename_all") {
let rule = both_ways(&meta)?;
if !RULES.contains(&rule.as_str()) {
return Err(meta.error(format!("zip::Wire: no rename_all rule {rule:?}")));
}
out.rename_all = Some(rule);
} else if meta.path.is_ident("transparent") {
out.transparent = true;
} else {
pass(&meta)?;
}
Ok(())
})?;
}
Ok(out)
}
}
/// Field is what a field told serde.
#[derive(Default)]
pub struct Field {
pub rename: Option<String>,
pub skip: bool,
pub flatten: bool,
pub with: Option<String>,
}
impl Field {
pub fn read(attrs: &[Attribute]) -> Result<Self, syn::Error> {
let mut out = Field::default();
let (mut ser, mut de) = (false, false);
for a in serde(attrs) {
a.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
out.rename = Some(both_ways(&meta)?);
} else if meta.path.is_ident("skip") {
out.skip = true;
} else if meta.path.is_ident("skip_serializing") {
ser = true;
} else if meta.path.is_ident("skip_deserializing") {
de = true;
} else if meta.path.is_ident("flatten") {
out.flatten = true;
} else if meta.path.is_ident("with") {
out.with = Some(meta.value()?.parse::<LitStr>()?.value());
} else {
pass(&meta)?;
}
Ok(())
})?;
}
out.skip |= ser && de;
Ok(out)
}
}
fn serde(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
attrs.iter().filter(|a| a.path().is_ident("serde"))
}
/// both_ways reads `name = "x"`, or `name(serialize = "x", deserialize = "x")`
/// when the two agree. A name that differs by direction is two names, and a
/// document can publish one.
fn both_ways(meta: &ParseNestedMeta) -> Result<String, syn::Error> {
if meta.input.peek(Token![=]) {
return Ok(meta.value()?.parse::<LitStr>()?.value());
}
let (mut ser, mut de) = (None, None);
meta.parse_nested_meta(|m| {
let v = m.value()?.parse::<LitStr>()?.value();
if m.path.is_ident("serialize") {
ser = Some(v);
} else if m.path.is_ident("deserialize") {
de = Some(v);
} else {
return Err(m.error("expected serialize or deserialize"));
}
Ok(())
})?;
match (ser, de) {
(Some(a), Some(b)) if a == b => Ok(a),
_ => Err(meta.error(
"zip::Wire: a name is the same both ways; one that differs by direction is two names, and a document publishes one",
)),
}
}
/// pass consumes an attribute this derive does not read, whatever its shape.
fn pass(meta: &ParseNestedMeta) -> Result<(), syn::Error> {
if meta.input.peek(Token![=]) {
meta.value()?.parse::<syn::Expr>()?;
} else if meta.input.peek(syn::token::Paren) {
let inner;
syn::parenthesized!(inner in meta.input);
inner.parse::<TokenStream>()?;
}
Ok(())
}
/// RULES are serde's rename_all rules.
const RULES: [&str; 8] = [
"lowercase",
"UPPERCASE",
"PascalCase",
"camelCase",
"snake_case",
"SCREAMING_SNAKE_CASE",
"kebab-case",
"SCREAMING-KEBAB-CASE",
];
/// case is a field's name under a rename_all rule, as serde spells it
/// (serde_derive's `RenameRule::apply_to_field`).
pub fn case(rule: &Option<String>, field: &str) -> String {
let Some(rule) = rule else {
return field.to_string();
};
match rule.as_str() {
"UPPERCASE" | "SCREAMING_SNAKE_CASE" => field.to_ascii_uppercase(),
"PascalCase" => pascal(field),
"camelCase" => {
let p = pascal(field);
let mut c = p.chars();
match c.next() {
Some(first) => first.to_ascii_lowercase().to_string() + c.as_str(),
None => p,
}
}
"kebab-case" => field.replace('_', "-"),
"SCREAMING-KEBAB-CASE" => field.to_ascii_uppercase().replace('_', "-"),
_ => field.to_string(), // lowercase and snake_case leave a field as it is.
}
}
fn pascal(field: &str) -> String {
let mut out = String::with_capacity(field.len());
let mut up = true;
for ch in field.chars() {
if ch == '_' {
up = true;
} else if up {
out.push(ch.to_ascii_uppercase());
up = false;
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rules_spell_as_serde_does() {
let r = |s: &str| Some(s.to_string());
assert_eq!(case(&None, "node_id"), "node_id");
assert_eq!(case(&r("camelCase"), "node_id"), "nodeId");
assert_eq!(case(&r("camelCase"), "max_tokens"), "maxTokens");
assert_eq!(case(&r("PascalCase"), "node_id"), "NodeId");
assert_eq!(case(&r("SCREAMING_SNAKE_CASE"), "node_id"), "NODE_ID");
assert_eq!(case(&r("kebab-case"), "node_id"), "node-id");
assert_eq!(case(&r("SCREAMING-KEBAB-CASE"), "node_id"), "NODE-ID");
assert_eq!(case(&r("lowercase"), "node_id"), "node_id");
assert_eq!(case(&r("snake_case"), "node_id"), "node_id");
}
}
+17 -1
View File
@@ -94,7 +94,12 @@ fn wrap(fields: &str, opt: bool) -> String {
pub fn prim(t: &Type) -> Option<&'static str> {
let name = match t {
Type::Reference(r) => return prim(&r.elem),
Type::Path(p) => p.path.segments.last()?.ident.to_string(),
Type::Path(p) => {
if any(&p.path) {
return Some("any");
}
p.path.segments.last()?.ident.to_string()
}
_ => return None,
};
Some(match name.as_str() {
@@ -114,6 +119,17 @@ pub fn prim(t: &Type) -> Option<&'static str> {
})
}
/// any says a path is serde_json's Value: any JSON at all, which is what a
/// relay holds when it passes a field through without reading it.
fn any(p: &syn::Path) -> bool {
let names: Vec<String> = p.segments.iter().map(|s| s.ident.to_string()).collect();
match names.as_slice() {
[v] => v == "Value",
[.., m, v] => m == "serde_json" && v == "Value",
[] => false,
}
}
fn generics(a: &PathArguments) -> Vec<&Type> {
match a {
PathArguments::AngleBracketed(b) => b
+107 -221
View File
@@ -10,16 +10,21 @@
//! names and prose, once, where the type is declared, and everything that
//! refers to the type refers to that.
//!
//! It emits two readers of one description. The `describe` a running service
//! binds a request with, and the fragment `zipc` projects — both from this one
//! pass, so what the document says a field is called is what the binder reads
//! it under.
//! The bytes are serde's. A field's wire name is the one serde reads it under —
//! its `#[serde(rename)]`, else the struct's `rename_all` applied to it, else
//! its own name — so the derive reads those attributes rather than asking for
//! the name a second time, and what the document says a field is called is what
//! the decoder reads it under. What serde cannot be told, and so this cannot
//! describe — a flattened field, a name that differs by direction — is refused
//! at compile time rather than published wrong.
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use quote::quote;
use syn::ext::IdentExt;
use syn::{Data, DeriveInput, Fields, Type};
use crate::attr::Attrs;
use crate::serde::{self as sd, Container};
use crate::{doc, frag, ty};
pub fn derive(input: DeriveInput) -> Result<TokenStream, syn::Error> {
@@ -27,6 +32,7 @@ pub fn derive(input: DeriveInput) -> Result<TokenStream, syn::Error> {
let id = name.to_string();
let own = Attrs::read(&input.attrs)?;
let prose = doc::read(&input.attrs);
let container = Container::read(&input.attrs)?;
let Data::Struct(s) = &input.data else {
return Err(syn::Error::new_spanned(
@@ -37,10 +43,16 @@ pub fn derive(input: DeriveInput) -> Result<TokenStream, syn::Error> {
match &s.fields {
Fields::Named(named) => {
if container.transparent {
return Err(syn::Error::new_spanned(
&input.ident,
"zip::Wire: a transparent struct is one value; write it as a newtype",
));
}
let fields: Vec<Field> = named
.named
.iter()
.map(Field::read)
.map(|f| Field::read(f, &container))
.collect::<Result<_, _>>()?;
Ok(shape(
&name,
@@ -51,7 +63,7 @@ pub fn derive(input: DeriveInput) -> Result<TokenStream, syn::Error> {
}
Fields::Unnamed(un) if un.unnamed.len() == 1 => {
let inner = &un.unnamed[0].ty;
Ok(newtype(&name, &id, &value(&id, &own, inner), inner, &own))
Ok(newtype(&name, &id, &value(&id, &own, inner), &own))
}
_ => Err(syn::Error::new_spanned(
&input.ident,
@@ -73,13 +85,29 @@ struct Field {
}
impl Field {
fn read(f: &syn::Field) -> Result<Self, syn::Error> {
fn read(f: &syn::Field, container: &Container) -> Result<Self, syn::Error> {
let a = Attrs::read(&f.attrs)?;
let name = f.ident.clone().expect("named field");
let json = if a.skip {
if a.json.is_some() {
return Err(syn::Error::new_spanned(
f,
"zip::Wire: a field's wire name is serde's — #[serde(rename = \"...\")]",
));
}
let serde = sd::Field::read(&f.attrs)?;
if serde.flatten {
return Err(syn::Error::new_spanned(
f,
"zip::Wire: a flattened field has no name of its own to describe; name the struct it holds as a field",
));
}
bytes(f, &f.ty, serde.with.as_deref())?;
let json = if serde.skip {
"-".to_string()
} else if let Some(n) = serde.rename {
n
} else {
a.json.clone().unwrap_or_else(|| name.to_string())
sd::case(&container.rename_all, &name.unraw().to_string())
};
Ok(Field {
json,
@@ -94,6 +122,37 @@ impl Field {
}
}
/// bytes refuses a byte field serde would write as an array of numbers. The
/// document says a byte field is base64 in a string, which is what Go writes,
/// so a `Vec<u8>` carries `#[serde(with = "zip::base64")]`, and one inside a
/// container — which that attribute cannot reach — is refused.
fn bytes(f: &syn::Field, t: &Type, with: Option<&str>) -> Result<(), syn::Error> {
if let Shape::Prim("bytes") = shape_of(t) {
if with == Some("zip::base64") {
return Ok(());
}
return Err(syn::Error::new_spanned(
f,
"zip::Wire: a byte field rides JSON as base64 — #[serde(with = \"zip::base64\")]",
));
}
if holds_bytes(t) {
return Err(syn::Error::new_spanned(
f,
"zip::Wire: bytes inside a container have no base64 spelling here; make them a field of their own type",
));
}
Ok(())
}
fn holds_bytes(t: &Type) -> bool {
match shape_of(t) {
Shape::Prim("bytes") => true,
Shape::Opt(inner) | Shape::List(inner) | Shape::Map(inner) => holds_bytes(&inner),
_ => false,
}
}
/// record is the fragment for a struct.
fn record(id: &str, prose: &str, fields: &[Field]) -> String {
let items: Vec<String> = fields
@@ -140,8 +199,8 @@ fn value(id: &str, a: &Attrs, inner: &Type) -> String {
o.finish()
}
/// shape is the impl for a struct: the description a binder reads, the entry a
/// manifest carries, and the JSON the wire binds.
/// shape is the impl for a struct: the description a binder reads and the
/// entry a manifest carries.
fn shape(name: &syn::Ident, id: &str, stated: &str, fields: &[Field]) -> TokenStream {
let descs = fields.iter().map(|f| {
let n = f.name.to_string();
@@ -155,30 +214,6 @@ fn shape(name: &syn::Ident, id: &str, stated: &str, fields: &[Field]) -> TokenSt
}
});
let writes = fields
.iter()
.filter(|f| f.json != "-")
.enumerate()
.map(|(i, f)| {
let j = &f.json;
let at = &f.name;
let lead = if i == 0 { "\"" } else { ",\"" };
let key = format!("{lead}{j}\":");
let w = write_of(&f.ty, quote!(&self.#at));
quote! { out.push_str(#key); #w; }
});
let reads = fields.iter().map(|f| {
let at = &f.name;
if f.json == "-" {
let d = default_of(&f.ty);
return quote! { #at: #d };
}
let j = &f.json;
let r = read_of(&f.ty, quote!(v.get(#j)));
quote! { #at: #r }
});
let reaches = fields
.iter()
.filter_map(|f| named_in(&f.ty))
@@ -202,62 +237,50 @@ fn shape(name: &syn::Ident, id: &str, stated: &str, fields: &[Field]) -> TokenSt
into.push((#id, #stated));
#(#reaches)*
}
fn write_json(&self, out: &mut ::std::string::String) {
out.push('{');
#(#writes)*
out.push('}');
}
fn read_json(v: &::zip::Json) -> ::std::result::Result<Self, ::zip::Error> {
Ok(#name { #(#reads),* })
}
}
}
}
/// newtype is the impl for a value type. `text` carries it as one word, which
/// is what a quoted decimal is and what a URL can hold.
fn newtype(name: &syn::Ident, id: &str, stated: &str, inner: &Type, a: &Attrs) -> TokenStream {
let says = quote! {
fn stated() -> &'static str { #stated }
fn reach(into: &mut ::std::vec::Vec<(&'static str, &'static str)>) {
if into.iter().all(|(k, _)| *k != #id) {
into.push((#id, #stated));
/// newtype is the impl for a value type. A `text` one is carried as one word —
/// what a quoted decimal is, and what a URL can hold — so its serde impls are
/// written here, from its Display and FromStr: that is what `text` means, and
/// serde has no derive that says it. Any other newtype is serde's, which
/// carries a newtype as the value it holds.
fn newtype(name: &syn::Ident, id: &str, stated: &str, a: &Attrs) -> TokenStream {
let a_text = a.text;
let wire = quote! {
impl ::zip::Wire for #name {
fn describe() -> &'static ::zip::TypeDesc {
static DESC: ::zip::TypeDesc = ::zip::TypeDesc { id: #id, text: #a_text, fields: &[] };
&DESC
}
fn stated() -> &'static str { #stated }
fn reach(into: &mut ::std::vec::Vec<(&'static str, &'static str)>) {
if into.iter().all(|(k, _)| *k != #id) {
into.push((#id, #stated));
}
}
}
};
if a.text {
return quote! {
impl ::zip::Wire for #name {
fn describe() -> &'static ::zip::TypeDesc {
static DESC: ::zip::TypeDesc = ::zip::TypeDesc { id: #id, text: true, fields: &[] };
&DESC
}
#says
fn write_json(&self, out: &mut ::std::string::String) {
::zip::json::write_str(out, &::std::string::ToString::to_string(&self.0));
}
fn read_json(v: &::zip::Json) -> ::std::result::Result<Self, ::zip::Error> {
let s = v.text()?;
match ::std::str::FromStr::from_str(s) {
Ok(n) => Ok(#name(n)),
Err(_) => Err(::zip::Error::bad(concat!("not a ", #id, ": "))),
}
}
}
};
if !a.text {
return wire;
}
let w = write_of(inner, quote!(&self.0));
let r = read_of(inner, quote!(::std::option::Option::Some(v)));
quote! {
impl ::zip::Wire for #name {
fn describe() -> &'static ::zip::TypeDesc {
static DESC: ::zip::TypeDesc = ::zip::TypeDesc { id: #id, text: false, fields: &[] };
&DESC
#wire
impl ::zip::serde::Serialize for #name {
fn serialize<S: ::zip::serde::Serializer>(&self, s: S) -> ::std::result::Result<S::Ok, S::Error> {
s.collect_str(&self.0)
}
#says
fn write_json(&self, out: &mut ::std::string::String) { #w; }
fn read_json(v: &::zip::Json) -> ::std::result::Result<Self, ::zip::Error> {
Ok(#name(#r))
}
impl<'de> ::zip::serde::Deserialize<'de> for #name {
fn deserialize<D: ::zip::serde::Deserializer<'de>>(d: D) -> ::std::result::Result<Self, D::Error> {
let text = <::std::string::String as ::zip::serde::Deserialize>::deserialize(d)?;
match ::std::str::FromStr::from_str(&text) {
Ok(v) => Ok(#name(v)),
Err(_) => Err(<D::Error as ::zip::serde::de::Error>::custom(
::std::format!("not a {}: {:?}", #id, text),
)),
}
}
}
}
@@ -272,6 +295,7 @@ fn scalar_of(t: &Type) -> TokenStream {
"bool" => quote!(::zip::Scalar::Bool),
"f32" | "f64" => quote!(::zip::Scalar::Number),
"bytes" => quote!(::zip::Scalar::Text),
"any" => quote!(::zip::Scalar::Body),
_ => quote!(::zip::Scalar::Number),
},
Shape::Opt(inner) => scalar_of(&inner),
@@ -327,6 +351,7 @@ fn shape_of(t: &Type) -> Shape {
};
match (name.as_str(), args.len()) {
("Option", 1) => Shape::Opt(args[0].clone()),
("Box", 1) | ("Arc", 1) | ("Rc", 1) => shape_of(&args[0]),
("Vec", 1) | ("VecDeque", 1) => {
if ty::prim(&args[0]) == Some("u8") {
Shape::Prim("bytes")
@@ -344,142 +369,3 @@ fn shape_of(t: &Type) -> Shape {
_ => Shape::Other,
}
}
/// write_of is the JSON one value writes.
///
/// Every case binds the value to a name first. A generated expression is
/// TOKENS, and `&self.chains` followed by `.iter()` is `&(self.chains.iter())`
/// — a reference to an iterator, which is not one. A binding has no precedence
/// to get wrong.
fn write_of(t: &Type, at: TokenStream) -> TokenStream {
match shape_of(t) {
Shape::Prim("string") => quote!({ let v = #at; ::zip::json::write_str(out, v); }),
Shape::Prim("bool") => {
quote!({ let v = #at; out.push_str(if *v { "true" } else { "false" }); })
}
Shape::Prim("bytes") => quote!({ let v = #at; ::zip::json::write_bytes(out, v); }),
Shape::Prim("f32") | Shape::Prim("f64") => {
quote!({ let v = #at; ::zip::json::write_float(out, *v as f64); })
}
Shape::Prim(_) => {
quote!({ let v = #at; out.push_str(&::std::string::ToString::to_string(v)); })
}
Shape::Opt(inner) => {
let w = write_of(&inner, quote!(it));
quote! {
{
let v = #at;
match v {
::std::option::Option::Some(it) => { #w; }
::std::option::Option::None => out.push_str("null"),
}
}
}
}
Shape::List(inner) => {
let w = write_of(&inner, quote!(it));
quote! {
{
let v = #at;
out.push('[');
for (i, it) in v.iter().enumerate() {
if i > 0 { out.push(','); }
#w;
}
out.push(']');
}
}
}
Shape::Map(inner) => {
let w = write_of(&inner, quote!(it));
quote! {
{
let v = #at;
// Sorted, because a map has no order and a document that
// changes shape between two identical answers is one nothing
// can diff.
let mut keys: ::std::vec::Vec<_> = v.keys().collect();
keys.sort();
out.push('{');
for (i, k) in keys.into_iter().enumerate() {
if i > 0 { out.push(','); }
::zip::json::write_str(out, k);
out.push(':');
let it = &v[k];
#w;
}
out.push('}');
}
}
}
_ => quote!({ let v = #at; ::zip::Wire::write_json(v, out); }),
}
}
/// read_of is one value, out of the JSON that arrived. `from` is an
/// Option<&Json>: a field the document did not carry reads as its zero, which
/// is what a decoder does everywhere else.
fn read_of(t: &Type, from: TokenStream) -> TokenStream {
match shape_of(t) {
Shape::Prim("string") => quote!(::zip::json::as_text(#from)?),
Shape::Prim("bool") => quote!(::zip::json::as_bool(#from)?),
Shape::Prim("bytes") => quote!(::zip::json::as_bytes(#from)?),
Shape::Prim(p) => {
let cast = format_ident!("{}", num_of(p));
quote!(::zip::json::as_number(#from)? as #cast)
}
Shape::Opt(inner) => {
let r = read_of(&inner, quote!(::std::option::Option::Some(one)));
quote! {
match #from {
::std::option::Option::Some(one) if !one.is_null() => ::std::option::Option::Some(#r),
_ => ::std::option::Option::None,
}
}
}
Shape::List(inner) => {
let r = read_of(&inner, quote!(::std::option::Option::Some(one)));
quote! {
{
let mut got = ::std::vec::Vec::new();
for one in ::zip::json::as_list(#from)? { got.push(#r); }
got
}
}
}
Shape::Map(inner) => {
let r = read_of(&inner, quote!(::std::option::Option::Some(one)));
quote! {
{
let mut got = ::std::collections::HashMap::new();
for (k, one) in ::zip::json::as_map(#from)? { got.insert(k.clone(), #r); }
got
}
}
}
_ => quote!(::zip::json::as_wire(#from)?),
}
}
/// default_of is the zero a field the wire does not carry starts at.
fn default_of(t: &Type) -> TokenStream {
match shape_of(t) {
Shape::Opt(_) => quote!(::std::option::Option::None),
_ => quote!(::std::default::Default::default()),
}
}
fn num_of(p: &str) -> &'static str {
match p {
"i8" => "i8",
"i16" => "i16",
"i32" => "i32",
"i64" => "i64",
"u8" => "u8",
"u16" => "u16",
"u32" => "u32",
"f32" => "f32",
"f64" => "f64",
_ => "u64",
}
}
+12
View File
@@ -7,3 +7,15 @@ description = "A ZAP-native web framework: one registration, every projection."
[dependencies]
zip-macros = { path = "../zip-macros" }
bytes = "1"
futures-util = { version = "0.3", default-features = false, features = ["std"] }
http-body-util = "0.1"
hyper = { version = "1.12", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1.21", features = ["server", "server-auto", "server-graceful", "http1", "http2", "tokio"] }
serde = "1"
serde_json = "1"
tokio = { version = "1.53", features = ["rt-multi-thread", "net", "time", "sync", "signal", "macros", "io-util"] }
[dev-dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.53", features = ["test-util"] }
-48
View File
@@ -1,48 +0,0 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The wire is generated, here, before the crate compiles.
//!
//! `zapgen` reads wire.zap and writes the ZAP runtime and the accessors for the
//! two frames this door speaks. It is a build-time tool and nothing it produces
//! carries a dependency: what ships is Rust, and what runs is Rust.
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=wire.zap");
println!("cargo:rerun-if-env-changed=ZAPGEN");
let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
let zapgen = std::env::var("ZAPGEN").unwrap_or_else(|_| "zapgen".to_string());
let run = Command::new(&zapgen)
.args(["-lang", "rust", "-out"])
.arg(&out)
.arg("wire.zap")
.output();
match run {
Ok(r) if r.status.success() => {
// One file holding both modules, because `include!` pastes tokens
// and a pasted file cannot carry the inner attributes a generated
// module begins with. Braced, they are ordinary items again.
let mut all =
String::from("// Written by zip's build script from what zapgen emitted.\n");
for (name, file) in [("zap", "zap.rs"), ("wire", "wire_zap.rs")] {
let body = std::fs::read_to_string(out.join(file))
.unwrap_or_else(|e| panic!("zapgen wrote no {file}: {e}"));
all.push_str(&format!("pub mod {name} {{\n{body}\n}}\n"));
}
std::fs::write(out.join("generated.rs"), all).expect("write generated.rs");
}
Ok(r) => panic!(
"zapgen refused wire.zap:\n{}",
String::from_utf8_lossy(&r.stderr)
),
Err(e) => panic!(
"zip needs zapgen to generate its wire ({zapgen}: {e}).\n\
Build it from zap-proto/go (cmd/zapgen) and put it on PATH, or set ZAPGEN to it."
),
}
}
+180 -174
View File
@@ -2,50 +2,35 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The app: a typed-op registry, and the two doors onto it.
//! The app: a typed-op registry, and the one path from a request to an answer.
//!
//! An op is registered once. What answers a REST request and what answers a ZAP
//! frame are the same closure, reached the same way — decode, run, write — so a
//! browser and a sibling service are talking to one handler and not to two
//! frame are the same future, reached the same way — route, bind, run, answer —
//! so a browser and a sibling service are talking to one handler and not to two
//! spellings of one.
use std::collections::HashMap;
use std::future::Future;
use std::net::SocketAddr;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex};
use crate::{Error, FieldDesc, Json, Scalar, TypeDesc, Wire};
use bytes::Bytes;
use futures_util::future::BoxFuture;
use futures_util::stream::BoxStream;
use futures_util::FutureExt;
use hyper::header::{self, HeaderMap, HeaderValue};
/// Input is one request, before it is a value: what the URL carried, what the
/// headers said, and the bytes of the body.
pub struct Input {
pub method: String,
pub path: String,
pub query: Vec<(String, String)>,
pub params: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
use crate::cx::{lock, Out};
use crate::{Body, Cx, Error, Reply};
impl Input {
/// header reads one request header, case-insensitively as HTTP names are.
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
/// Call is what an op does: a request in, the answer's JSON out. None is an op
/// that answers nothing, which is a 204.
pub type Call = Box<dyn Fn(&Input) -> Result<Option<String>, Error> + Send + Sync>;
type Call = Box<dyn Fn(Cx, Body) -> BoxFuture<'static, Result<Reply, Error>> + Send + Sync>;
/// Op is one registered operation.
pub struct Op {
pub method: &'static str,
pub path: &'static str,
/// input is the description of what this op takes, or None for an op that
/// takes nothing.
pub input: Option<&'static TypeDesc>,
pub call: Call,
call: Call,
segments: Vec<Segment>,
}
@@ -65,6 +50,54 @@ pub struct App {
files: HashMap<String, (&'static str, &'static str)>,
}
/// Asked is one request as a door read it.
pub(crate) struct Asked {
pub method: String,
pub target: String,
pub headers: HeaderMap,
pub peer: Option<SocketAddr>,
pub body: Body,
}
/// Answer is what a door writes back: a head, then a body that is either in
/// hand or still being produced, then — when the op declared any — trailers.
pub(crate) struct Answer {
pub status: u16,
pub headers: HeaderMap,
pub payload: Payload,
/// trailers is the handler's state when it declared a trailer, read once
/// the payload has ended.
pub trailers: Option<Arc<Mutex<Out>>>,
}
pub(crate) enum Payload {
Full(Bytes),
Stream(BoxStream<'static, Result<Bytes, Error>>),
}
impl Answer {
pub(crate) fn refusal(e: &Error, headers: HeaderMap) -> Answer {
let mut headers = headers;
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/problem+json"),
);
Answer {
status: e.status,
headers,
payload: Payload::Full(Bytes::from(e.problem())),
trailers: None,
}
}
}
/// trailers is what the handler set by the time the body ended, or None when
/// it set nothing.
pub(crate) fn trailers(out: &Mutex<Out>) -> Option<HeaderMap> {
let t = std::mem::take(&mut lock(out).trailers);
(!t.is_empty()).then_some(t)
}
impl App {
pub fn new(
name: &'static str,
@@ -83,18 +116,15 @@ impl App {
}
/// op registers one operation. The macro calls this; a service does not.
pub fn op(
&mut self,
method: &'static str,
path: &'static str,
input: Option<&'static TypeDesc>,
call: Call,
) -> &mut Self {
pub fn op<F, R>(&mut self, method: &'static str, path: &'static str, call: F) -> &mut Self
where
F: Fn(Cx, Body) -> R + Send + Sync + 'static,
R: Future<Output = Result<Reply, Error>> + Send + 'static,
{
self.ops.push(Op {
method,
path,
input,
call,
call: Box::new(move |cx, body| Box::pin(call(cx, body))),
segments: split(path),
});
self
@@ -116,22 +146,19 @@ impl App {
self.ops.iter().map(|o| (o.method, o.path)).collect()
}
/// answer runs a request: find the op, decode, run, write. It is the ONE
/// path in, so a REST request and a ZAP frame cannot diverge.
pub fn answer(
&self,
method: &str,
target: &str,
headers: Vec<(String, String)>,
body: Vec<u8>,
) -> Answer {
let (path, query) = split_target(target);
if method == "GET" {
/// answer runs a request: route, bind, run, answer. It is the ONE path in,
/// so a REST request and a ZAP frame cannot diverge.
pub(crate) async fn answer(&self, asked: Asked) -> Answer {
let (path, query) = split_target(&asked.target);
if asked.method == "GET" {
if let Some((kind, text)) = self.files.get(path) {
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(kind));
return Answer {
status: 200,
kind,
body: text.as_bytes().to_vec(),
headers,
payload: Payload::Full(Bytes::from_static(text.as_bytes())),
trailers: None,
};
}
}
@@ -140,54 +167,85 @@ impl App {
let Some(params) = op.bind_path(path) else {
continue;
};
if op.method != method {
if op.method != asked.method {
allowed = true;
continue;
}
let input = Input {
method: method.to_string(),
path: path.to_string(),
let cx = Cx::new(
asked.method,
path.to_string(),
query,
params,
headers,
body,
};
return match (op.call)(&input) {
Ok(Some(json)) => Answer {
status: 200,
kind: "application/json",
body: json.into_bytes(),
},
Ok(None) => Answer {
status: 204,
kind: "application/json",
body: Vec::new(),
},
Err(e) => Answer {
status: e.status,
kind: "application/problem+json",
body: e.problem().into_bytes(),
},
asked.headers,
asked.peer,
);
let out = Arc::clone(&cx.out);
let run = AssertUnwindSafe((op.call)(cx, asked.body)).catch_unwind();
let reply = match run.await {
Ok(r) => r,
Err(_) => Err(Error::broke("the handler panicked")),
};
return reply_to(reply, out);
}
let e = if allowed {
Error::new(405, "that address does not answer this method")
} else {
Error::missing("no such address")
};
Answer {
status: e.status,
kind: "application/problem+json",
body: e.problem().into_bytes(),
}
Answer::refusal(&e, HeaderMap::new())
}
}
/// Answer is what a door writes back.
pub struct Answer {
pub status: u16,
pub kind: &'static str,
pub body: Vec<u8>,
/// reply_to is a reply as a head and a body: the kind's own status and content
/// type, then whatever the handler set on its Cx over them.
fn reply_to(reply: Result<Reply, Error>, out: Arc<Mutex<Out>>) -> Answer {
let (status, mut headers, declared) = {
let mut o = lock(&out);
(
o.status.take(),
std::mem::take(&mut o.headers),
std::mem::take(&mut o.declared),
)
};
let reply = match reply {
Ok(r) => r,
Err(e) => return Answer::refusal(&e, headers),
};
let (dflt, kind, payload) = match reply {
Reply::Json(b) => (200, Some("application/json"), Payload::Full(b)),
Reply::Empty => (204, None, Payload::Full(Bytes::new())),
Reply::Sse(s) => {
headers
.entry(header::CACHE_CONTROL)
.or_insert(HeaderValue::from_static("no-cache"));
(200, Some("text/event-stream"), Payload::Stream(s.bytes()))
}
Reply::Body(b) => (
200,
Some("application/octet-stream"),
Payload::Stream(Box::pin(b)),
),
};
if let Some(kind) = kind {
headers
.entry(header::CONTENT_TYPE)
.or_insert(HeaderValue::from_static(kind));
}
let trailers = if declared.is_empty() {
None
} else {
let names: Vec<&str> = declared.iter().map(|n| n.as_str()).collect();
let value = HeaderValue::from_str(&names.join(", "))
.expect("header names joined by commas are a header value");
headers.insert(header::TRAILER, value);
Some(out)
};
Answer {
status: status.unwrap_or(dflt),
headers,
payload,
trailers,
}
}
impl Op {
@@ -285,98 +343,46 @@ fn unescape(s: &str) -> String {
String::from_utf8_lossy(&out).into_owned()
}
/// bind is one request as the value the handler takes.
///
/// The body carries what a body can, and the URL carries the rest — and where
/// both spoke, the URL wins, because a URL is what addresses the resource. That
/// is one rule for every op: a GET's whole input arrives in the URL and a POST's
/// path parameters still bind, without either being a special case.
pub fn bind<T: Wire>(input: &Input) -> Result<T, Error> {
let desc = T::describe();
let mut object = match body_of(input)? {
Json::Map(m) => m,
Json::Null => HashMap::new(),
other => return T::read_json(&other), // the body IS the whole value
};
for f in desc.fields {
if !f.header.is_empty() {
if let Some(v) = input.header(f.header) {
object.insert(f.json.to_string(), scalar(v, &f.scalar));
}
#[cfg(test)]
mod tests {
use super::*;
fn op(path: &'static str) -> Op {
Op {
method: "GET",
path,
call: Box::new(|_, _| Box::pin(async { Ok(Reply::Empty) })),
segments: split(path),
}
}
for (name, value) in input.query.iter().chain(input.params.iter()) {
if let Some(f) = field_for(desc, name) {
object.insert(f.json.to_string(), scalar(value, &f.scalar));
}
}
require(desc, &object)?;
T::read_json(&Json::Map(object))
}
/// require refuses a request missing a value the op declared it cannot run
/// without.
///
/// It runs HERE, on the bound input, and not in the handler: the document says
/// the field is required, so a service that only checked it in some handlers
/// would publish a contract it keeps by habit. A field is missing when it is
/// absent or when it is the zero its type reads as — the same rule the Go side
/// applies, so one client sees one answer from either.
fn require(desc: &'static TypeDesc, object: &HashMap<String, Json>) -> Result<(), Error> {
for f in desc.fields {
if !f.required {
continue;
}
let given = match object.get(f.json) {
Some(Json::Text(s)) => !s.is_empty(),
Some(Json::Number(n)) => *n != 0.0,
Some(Json::Bool(b)) => *b,
Some(Json::List(l)) => !l.is_empty(),
Some(Json::Map(m)) => !m.is_empty(),
Some(Json::Null) | None => false,
};
if !given {
return Err(Error::bad(format!("field {:?} is required", f.json)));
}
#[test]
fn a_pattern_binds_what_it_names() {
let o = op("/v1/models/:id");
assert_eq!(
o.bind_path("/v1/models/kai"),
Some(vec![("id".to_string(), "kai".to_string())])
);
assert_eq!(o.bind_path("/v1/models"), None);
assert_eq!(o.bind_path("/v1/models/kai/x"), None);
let rest = op("/files/*");
assert_eq!(
rest.bind_path("/files/a/b"),
Some(vec![("*1".to_string(), "a/b".to_string())])
);
}
Ok(())
}
fn body_of(input: &Input) -> Result<Json, Error> {
if input.body.is_empty() {
return Ok(Json::Null);
}
let text = std::str::from_utf8(&input.body).map_err(|_| Error::bad("the body is not utf-8"))?;
if text.trim().is_empty() {
return Ok(Json::Null);
}
Json::parse(text)
}
/// field_for is the field a URL name binds to. "-" opts out, exactly as it does
/// for the body: a field can be body-only and a field can be URL-only, and the
/// two halves are asked separately because a route may mean two things by one
/// word.
fn field_for(desc: &'static TypeDesc, name: &str) -> Option<&'static FieldDesc> {
desc.fields.iter().find(|f| {
let url = f.url_name();
url != "-" && url.eq_ignore_ascii_case(name)
})
}
/// scalar is one URL value as the JSON its field expects. A list is spelled
/// comma-separated in one value, which is what `style: form, explode: false`
/// already publishes for a repeated parameter.
fn scalar(text: &str, want: &Scalar) -> Json {
match want {
Scalar::Bool => Json::Bool(text == "true" || text == "1"),
Scalar::Number => text.parse().map(Json::Number).unwrap_or(Json::Null),
Scalar::List(elem) => Json::List(
text.split(',')
.filter(|s| !s.is_empty())
.map(|s| scalar(s, elem))
.collect(),
),
_ => Json::Text(text.to_string()),
#[test]
fn a_query_is_unescaped() {
let (path, q) = split_target("/x?a=1%202&b=c+d&e");
assert_eq!(path, "/x");
assert_eq!(
q,
vec![
("a".to_string(), "1 2".to_string()),
("b".to_string(), "c d".to_string()),
("e".to_string(), String::new()),
]
);
}
}
+93
View File
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! A byte field, as JSON carries one: standard base64 in a string.
//!
//! serde writes a `Vec<u8>` as an array of numbers, which is not what the
//! document says a byte field is (`string`, `contentEncoding: base64`) nor what
//! Go's encoder writes. A byte field therefore names this module, and the
//! derive refuses one that does not:
//!
//! ```ignore
//! #[serde(with = "zip::base64")]
//! pub payload: Vec<u8>,
//! ```
use serde::{Deserialize, Deserializer, Serializer};
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let text = String::deserialize(d)?;
decode(&text).map_err(serde::de::Error::custom)
}
/// encode is bytes as base64, padded.
pub fn encode(b: &[u8]) -> String {
let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
for c in b.chunks(3) {
let n = (c[0] as u32) << 16
| (*c.get(1).unwrap_or(&0) as u32) << 8
| (*c.get(2).unwrap_or(&0) as u32);
s.push(ALPHABET[(n >> 18 & 63) as usize] as char);
s.push(ALPHABET[(n >> 12 & 63) as usize] as char);
s.push(if c.len() > 1 {
ALPHABET[(n >> 6 & 63) as usize] as char
} else {
'='
});
s.push(if c.len() > 2 {
ALPHABET[(n & 63) as usize] as char
} else {
'='
});
}
s
}
/// decode is base64, back. Padding and line breaks are skipped; any other
/// byte outside the alphabet is a refusal.
pub fn decode(s: &str) -> Result<Vec<u8>, &'static str> {
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let mut buf = 0u32;
let mut have = 0;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' | b'\n' | b'\r' => continue,
_ => return Err("not base64"),
} as u32;
buf = buf << 6 | v;
have += 6;
if have >= 8 {
have -= 8;
out.push((buf >> have) as u8);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips() {
for n in 0..10 {
let b: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
assert_eq!(decode(&encode(&b)).unwrap(), b);
}
assert_eq!(encode(b"zip"), "emlw");
assert_eq!(encode(b"zi"), "emk=");
assert!(decode("a*b").is_err());
}
}
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! One request, as the value the handler takes.
//!
//! The body carries what a body can, and the URL carries the rest — and where
//! both spoke, the URL wins, because a URL is what addresses the resource. That
//! is one rule for every op: a GET's whole input arrives in the URL and a POST's
//! path parameters still bind, without either being a special case.
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::{Body, Cx, Error, FieldDesc, Scalar, TypeDesc, Wire};
/// MAX_BODY is the largest body an op's input is read from, the ZAP door's
/// frame bound. An op that must take more takes a `Body` and reads it as it
/// arrives.
pub const MAX_BODY: usize = 32 << 20;
/// bind reads the body and binds the request to the op's input.
pub async fn bind<T: Wire + DeserializeOwned>(cx: &Cx, body: Body) -> Result<T, Error> {
// A length past the bound is refused on its say-so, before a byte of it is
// waited for; a body that does not declare one is counted as it arrives.
if let Some(n) = cx
.header("content-length")
.and_then(|v| v.parse::<usize>().ok())
{
if n > MAX_BODY {
return Err(Error::new(
413,
format!("the body is over {MAX_BODY} bytes"),
));
}
}
let bytes = body.bytes(MAX_BODY).await?;
value::<T>(cx, &bytes)
}
/// value is [bind] over a body already read.
pub fn value<T: Wire + DeserializeOwned>(cx: &Cx, body: &[u8]) -> Result<T, Error> {
let desc = T::describe();
let first = body.iter().find(|b| !b.is_ascii_whitespace());
// What the URL and the headers add to the body. When they add nothing and
// nothing is required, the body is the value and is read once, straight
// into it: a chat request with an image in it is not copied through a tree
// first.
let mut from = Vec::new();
for f in desc.fields {
if !f.header.is_empty() {
if let Some(v) = cx.header(f.header) {
from.push((f, v));
}
}
}
for (name, v) in cx.query.iter().chain(cx.params.iter()) {
if let Some(f) = field_for(desc, name) {
from.push((f, v.as_str()));
}
}
let required = desc.fields.iter().any(|f| f.required);
if from.is_empty() && !required && first == Some(&b'{') {
return serde_json::from_slice(body).map_err(refuse);
}
let mut object = if first.is_none() {
Map::new()
} else {
match serde_json::from_slice::<Value>(body).map_err(refuse)? {
Value::Object(m) => m,
Value::Null => Map::new(),
other => return serde_json::from_value(other).map_err(refuse), // the body IS the whole value
}
};
for (f, v) in from {
object.insert(f.json.to_string(), scalar(v, &f.scalar));
}
require(desc, &object)?;
serde_json::from_value(Value::Object(object)).map_err(refuse)
}
fn refuse(e: serde_json::Error) -> Error {
Error::bad(format!("the input does not read: {e}"))
}
/// require refuses a request missing a value the op declared it cannot run
/// without.
///
/// It runs HERE, on the bound input, and not in the handler: the document says
/// the field is required, so a service that only checked it in some handlers
/// would publish a contract it keeps by habit. A field is missing when it is
/// absent or when it is the zero its type reads as — the same rule the Go side
/// applies, so one client sees one answer from either.
fn require(desc: &'static TypeDesc, object: &Map<String, Value>) -> Result<(), Error> {
for f in desc.fields {
if !f.required {
continue;
}
let given = match object.get(f.json) {
Some(Value::String(s)) => !s.is_empty(),
Some(Value::Number(n)) => n.as_f64() != Some(0.0),
Some(Value::Bool(b)) => *b,
Some(Value::Array(l)) => !l.is_empty(),
Some(Value::Object(m)) => !m.is_empty(),
Some(Value::Null) | None => false,
};
if !given {
return Err(Error::bad(format!("field {:?} is required", f.json)));
}
}
Ok(())
}
/// field_for is the field a URL name binds to. "-" opts out, exactly as it does
/// for the body: a field can be body-only and a field can be URL-only, and the
/// two halves are asked separately because a route may mean two things by one
/// word.
fn field_for(desc: &'static TypeDesc, name: &str) -> Option<&'static FieldDesc> {
desc.fields.iter().find(|f| {
let url = f.url_name();
url != "-" && url.eq_ignore_ascii_case(name)
})
}
/// scalar is one URL value as the JSON its field expects. A list is spelled
/// comma-separated in one value, which is what `style: form, explode: false`
/// already publishes for a repeated parameter. A number is read as an integer
/// when it is one, since serde will not read 1.0 into a u32.
fn scalar(text: &str, want: &Scalar) -> Value {
match want {
Scalar::Bool => Value::Bool(text == "true" || text == "1"),
Scalar::Number => {
if let Ok(i) = text.parse::<i64>() {
Value::from(i)
} else if let Ok(u) = text.parse::<u64>() {
Value::from(u)
} else {
text.parse::<f64>().map(Value::from).unwrap_or(Value::Null)
}
}
Scalar::List(elem) => Value::Array(
text.split(',')
.filter(|s| !s.is_empty())
.map(|s| scalar(s, elem))
.collect(),
),
_ => Value::String(text.to_string()),
}
}
+426
View File
@@ -0,0 +1,426 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! What an op answers, and the bytes it reads as they arrive.
//!
//! An op answers one JSON value, nothing, a stream of server-sent events, or a
//! stream of bytes. The two streams are values the handler returns, not writers
//! it is handed: a door polls them, so a chunk goes out when the producer yields
//! it, and when the peer goes away the door drops the stream — which drops
//! whatever the producer was holding, an upstream request included.
//!
//! A stream item is a `Result`. `Ok` is written; `Err` ends the answer without
//! its terminator — a truncated chunked body over HTTP/1.1, a reset stream over
//! HTTP/2 — because the status went out with the head and is no longer a thing
//! that can say the answer failed. A producer that wants to say so in words
//! sends an event first and then ends.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use bytes::Bytes;
use futures_util::stream::{BoxStream, Stream, StreamExt};
use serde::Serialize;
use crate::Error;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// failed is a stream's error as zip's: a zip::Error keeps its status and
/// detail, anything else is the service's own fault.
fn failed(e: BoxError) -> Error {
match e.downcast::<Error>() {
Ok(e) => *e,
Err(e) => Error::broke(e.to_string()),
}
}
/// Body is bytes as they arrive: an op's request body, read as sent, or its
/// answer, written as produced.
pub struct Body {
chunks: BoxStream<'static, Result<Bytes, Error>>,
}
impl Body {
/// new is a body from any stream of chunks. An error item ends the body
/// unfinished.
pub fn new<S, B, E>(chunks: S) -> Body
where
S: Stream<Item = Result<B, E>> + Send + 'static,
B: Into<Bytes>,
E: Into<BoxError>,
{
Body {
chunks: chunks
.map(|c| c.map(Into::into).map_err(|e| failed(e.into())))
.boxed(),
}
}
/// empty is a body with nothing in it.
pub fn empty() -> Body {
Body {
chunks: futures_util::stream::empty().boxed(),
}
}
/// full is a body that is one chunk, already in hand.
pub fn full(bytes: impl Into<Bytes>) -> Body {
let bytes = bytes.into();
if bytes.is_empty() {
return Body::empty();
}
Body {
chunks: futures_util::stream::once(async move { Ok(bytes) }).boxed(),
}
}
/// bytes reads the whole body, refusing one past `limit` with a 413 as soon
/// as it is past it — before the rest is read, not after.
pub async fn bytes(mut self, limit: usize) -> Result<Bytes, Error> {
let Some(first) = self.chunks.next().await else {
return Ok(Bytes::new());
};
let first = first?;
let Some(second) = self.chunks.next().await else {
return within(first, limit);
};
let mut all = Vec::with_capacity(first.len() * 2);
all.extend_from_slice(&first);
let mut next = Some(second);
while let Some(chunk) = next {
let chunk = chunk?;
if all.len() + chunk.len() > limit {
return Err(too_large(limit));
}
all.extend_from_slice(&chunk);
next = self.chunks.next().await;
}
Ok(Bytes::from(all))
}
}
fn within(b: Bytes, limit: usize) -> Result<Bytes, Error> {
if b.len() > limit {
return Err(too_large(limit));
}
Ok(b)
}
fn too_large(limit: usize) -> Error {
Error::new(413, format!("the body is over {limit} bytes"))
}
impl Stream for Body {
type Item = Result<Bytes, Error>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.chunks.poll_next_unpin(cx)
}
}
/// Event is one server-sent event.
///
/// Every field is optional on the wire and each is written as its own line, so
/// a value holding a line break is split into as many lines as it holds and
/// cannot start a field of its own: `data` and comments become several lines
/// of the same field, and `event` and `id`, which are one line by definition,
/// lose their line breaks.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Event {
data: Option<String>,
event: Option<String>,
id: Option<String>,
retry: Option<u64>,
comment: Option<String>,
}
impl Event {
/// data is an event carrying text.
pub fn data(text: impl Into<String>) -> Event {
Event {
data: Some(text.into()),
..Event::default()
}
}
/// json is an event carrying one JSON value, on one line.
pub fn json<T: Serialize>(value: &T) -> Result<Event, Error> {
serde_json::to_string(value)
.map(Event::data)
.map_err(|e| Error::broke(format!("an event did not encode: {e}")))
}
/// comment is a line a client ignores. It is what keeps an idle stream
/// open through a proxy that closes a quiet one.
pub fn comment(text: impl Into<String>) -> Event {
Event {
comment: Some(text.into()),
..Event::default()
}
}
/// event names the kind of event, which a browser dispatches on.
pub fn event(mut self, name: impl Into<String>) -> Event {
self.event = Some(one_line(name.into()));
self
}
/// id is the id a reconnecting client sends back as Last-Event-ID.
pub fn id(mut self, id: impl Into<String>) -> Event {
self.id = Some(one_line(id.into()));
self
}
/// retry is how long, in milliseconds, a client waits before reconnecting.
pub fn retry(mut self, ms: u64) -> Event {
self.retry = Some(ms);
self
}
/// encode is this event as the bytes the stream carries.
pub fn encode(&self) -> Bytes {
let mut out = String::new();
if let Some(c) = &self.comment {
for line in lines(c) {
out.push(':');
if !line.is_empty() {
out.push(' ');
out.push_str(line);
}
out.push('\n');
}
}
if let Some(e) = &self.event {
out.push_str("event: ");
out.push_str(e);
out.push('\n');
}
if let Some(id) = &self.id {
out.push_str("id: ");
out.push_str(id);
out.push('\n');
}
if let Some(ms) = self.retry {
out.push_str("retry: ");
out.push_str(&ms.to_string());
out.push('\n');
}
if let Some(d) = &self.data {
for line in lines(d) {
out.push_str("data: ");
out.push_str(line);
out.push('\n');
}
}
out.push('\n');
Bytes::from(out)
}
}
/// lines splits on every line break the event-stream format knows: CRLF, LF
/// and a lone CR.
fn lines(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let b = s.as_bytes();
let mut start = 0;
let mut i = 0;
while i < b.len() {
match b[i] {
b'\n' => {
out.push(&s[start..i]);
start = i + 1;
}
b'\r' => {
out.push(&s[start..i]);
if b.get(i + 1) == Some(&b'\n') {
i += 1;
}
start = i + 1;
}
_ => {}
}
i += 1;
}
out.push(&s[start..]);
out
}
fn one_line(s: String) -> String {
if s.contains(['\r', '\n']) {
s.replace(['\r', '\n'], "")
} else {
s
}
}
/// Sse is an answer of server-sent events (`text/event-stream`), each written
/// and flushed as the producer yields it.
pub struct Sse {
events: BoxStream<'static, Result<Event, Error>>,
keep: Option<Duration>,
}
impl Sse {
/// new is an event stream from any stream of events. An error item ends the
/// stream unfinished; the stream ending ends the answer cleanly.
pub fn new<S, E>(events: S) -> Sse
where
S: Stream<Item = Result<Event, E>> + Send + 'static,
E: Into<BoxError>,
{
Sse {
events: events.map(|e| e.map_err(|e| failed(e.into()))).boxed(),
keep: None,
}
}
/// keep writes a comment line whenever `every` passes with no event, so a
/// proxy does not close a stream that is only waiting, and a client that
/// left is found out by the write.
pub fn keep(mut self, every: Duration) -> Sse {
self.keep = Some(every);
self
}
/// bytes is the stream as the door writes it.
pub(crate) fn bytes(self) -> BoxStream<'static, Result<Bytes, Error>> {
match self.keep {
None => self.events.map(|e| e.map(|e| e.encode())).boxed(),
Some(every) => Keep {
events: self.events,
every,
timer: Box::pin(tokio::time::sleep(every)),
}
.boxed(),
}
}
}
/// Keep is an event stream with a comment line in every quiet interval.
struct Keep {
events: BoxStream<'static, Result<Event, Error>>,
every: Duration,
timer: Pin<Box<tokio::time::Sleep>>,
}
/// KEEP is the line a quiet interval writes: an empty comment.
const KEEP: &[u8] = b":\n\n";
impl Stream for Keep {
type Item = Result<Bytes, Error>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
if let Poll::Ready(next) = this.events.poll_next_unpin(cx) {
let at = tokio::time::Instant::now() + this.every;
this.timer.as_mut().reset(at);
return Poll::Ready(next.map(|e| e.map(|e| e.encode())));
}
if this.timer.as_mut().poll(cx).is_ready() {
let at = tokio::time::Instant::now() + this.every;
this.timer.as_mut().reset(at);
return Poll::Ready(Some(Ok(Bytes::from_static(KEEP))));
}
Poll::Pending
}
}
/// Reply is what an op answered. The macro builds it from the handler's return
/// value; a handler names `Sse`, `Body` or a type of its own and never this.
pub enum Reply {
/// Json is one value, encoded: 200, `application/json`.
Json(Bytes),
/// Empty is an op that answers nothing: 204.
Empty,
/// Sse is an event stream: 200, `text/event-stream`.
Sse(Sse),
/// Body is a byte stream: 200, `application/octet-stream` unless the
/// handler set a type.
Body(Body),
}
impl Reply {
/// json encodes one answer.
pub fn json<T: Serialize>(value: &T) -> Result<Reply, Error> {
serde_json::to_vec(value)
.map(|b| Reply::Json(Bytes::from(b)))
.map_err(|e| Error::broke(format!("the answer did not encode: {e}")))
}
}
impl From<Sse> for Reply {
fn from(s: Sse) -> Reply {
Reply::Sse(s)
}
}
impl From<Body> for Reply {
fn from(b: Body) -> Reply {
Reply::Body(b)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_event_is_its_fields_then_a_blank_line() {
assert_eq!(&Event::data("hi").encode()[..], b"data: hi\n\n");
assert_eq!(
&Event::data("a\nb\r\nc\rd")
.event("delta")
.id("7")
.retry(500)
.encode()[..],
b"event: delta\nid: 7\nretry: 500\ndata: a\ndata: b\ndata: c\ndata: d\n\n"
);
assert_eq!(&Event::comment("").encode()[..], b":\n\n");
assert_eq!(&Event::comment("ping").encode()[..], b": ping\n\n");
// A line break cannot start a field of its own.
assert_eq!(
&Event::data("x").event("a\ndata: forged").encode()[..],
b"event: adata: forged\ndata: x\n\n"
);
let e = Event::json(&serde_json::json!({"a": "b\nc"})).unwrap();
assert_eq!(&e.encode()[..], b"data: {\"a\":\"b\\nc\"}\n\n");
}
#[tokio::test]
async fn a_body_past_its_limit_is_refused() {
let chunks = futures_util::stream::iter(vec![
Ok::<_, Error>(Bytes::from_static(b"12345")),
Ok(Bytes::from_static(b"678")),
]);
let got = Body::new(chunks).bytes(6).await.unwrap_err();
assert_eq!(got.status, 413);
let chunks = futures_util::stream::iter(vec![
Ok::<_, Error>(Bytes::from_static(b"12345")),
Ok(Bytes::from_static(b"678")),
]);
assert_eq!(&Body::new(chunks).bytes(8).await.unwrap()[..], b"12345678");
assert_eq!(
Body::full("1234567").bytes(6).await.unwrap_err().status,
413
);
assert!(Body::empty().bytes(0).await.unwrap().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_quiet_stream_keeps_itself_open() {
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Error>>(1);
let mut s = Sse::new(futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|e| (e, rx))
}))
.keep(Duration::from_secs(15))
.bytes();
// Nothing for 15 s: a comment.
assert_eq!(&s.next().await.unwrap().unwrap()[..], KEEP);
tx.send(Ok(Event::data("1"))).await.unwrap();
assert_eq!(&s.next().await.unwrap().unwrap()[..], b"data: 1\n\n");
drop(tx);
assert!(s.next().await.is_none());
}
}
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! One request, as a handler sees it beside its input.
//!
//! An op's input is the value the request carried; `Cx` is the rest of the
//! exchange: the headers as they arrived, and what the answer carries besides
//! its body — its status, its headers and its trailers. A handler that takes
//! `&zip::Cx` gets one; most do not need it, since a header the op depends on
//! is better declared on the input (`#[zip(header = "X-Org-Id")]`), where the
//! document can see it.
use std::net::SocketAddr;
use std::sync::{Arc, Mutex, MutexGuard};
use hyper::header::{HeaderMap, HeaderName, HeaderValue};
use crate::Error;
/// Cx is the exchange around an op's input.
pub struct Cx {
method: String,
path: String,
pub(crate) query: Vec<(String, String)>,
pub(crate) params: Vec<(String, String)>,
headers: HeaderMap,
peer: Option<SocketAddr>,
pub(crate) out: Arc<Mutex<Out>>,
}
/// Out is what the handler said about its answer besides the body.
#[derive(Default)]
pub(crate) struct Out {
pub status: Option<u16>,
pub headers: HeaderMap,
/// declared is every trailer name, in the order declared. Over HTTP/1.1 a
/// trailer reaches the client only if its name went out in the head.
pub declared: Vec<HeaderName>,
pub trailers: HeaderMap,
}
impl Cx {
pub(crate) fn new(
method: String,
path: String,
query: Vec<(String, String)>,
params: Vec<(String, String)>,
headers: HeaderMap,
peer: Option<SocketAddr>,
) -> Cx {
Cx {
method,
path,
query,
params,
headers,
peer,
out: Arc::default(),
}
}
pub fn method(&self) -> &str {
&self.method
}
/// path is the address asked for, without its query.
pub fn path(&self) -> &str {
&self.path
}
/// headers is every request header as it arrived.
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
/// header is one request header, if it is there and is text.
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|v| v.to_str().ok())
}
/// peer is the socket's other end. Behind a gateway it is the gateway.
pub fn peer(&self) -> Option<SocketAddr> {
self.peer
}
/// set_status answers with this status instead of 200 (or 204 for an op
/// that answers nothing). A refusal keeps the status it carries.
pub fn set_status(&self, status: u16) -> Result<(), Error> {
if !(200..600).contains(&status) {
return Err(Error::broke(format!("{status} is not a final status")));
}
self.out().status = Some(status);
Ok(())
}
/// set_header sets one answer header, replacing any value it had —
/// including a content type the answer would otherwise carry.
pub fn set_header(&self, name: &str, value: &str) -> Result<(), Error> {
let (name, value) = pair(name, value)?;
self.out().headers.insert(name, value);
Ok(())
}
/// add_header adds one answer header beside any it already has.
pub fn add_header(&self, name: &str, value: &str) -> Result<(), Error> {
let (name, value) = pair(name, value)?;
self.out().headers.append(name, value);
Ok(())
}
/// trailer declares a trailer, and is the handle that sets it.
///
/// The name goes out in the head (`Trailer: name`) and the value after the
/// last chunk of the body, so a stream can report what it only knows once
/// it is done — a token count, a digest. Over HTTP/2 a trailer always
/// arrives. Over HTTP/1.1 it arrives only when the client asked for
/// trailers (`TE: trailers`), because hyper sends them on no other terms;
/// a client that did not ask gets the body alone.
pub fn trailer(&self, name: &str) -> Result<Trailer, Error> {
let name = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| Error::broke(format!("{name:?} is not a header name")))?;
let mut out = self.out();
if !out.declared.contains(&name) {
out.declared.push(name.clone());
}
Ok(Trailer {
name,
out: Arc::clone(&self.out),
})
}
fn out(&self) -> MutexGuard<'_, Out> {
lock(&self.out)
}
}
/// Trailer is one declared trailer. It is `Send` and `'static`, so the stream
/// that produces the body can carry it to the end.
#[derive(Clone)]
pub struct Trailer {
name: HeaderName,
out: Arc<Mutex<Out>>,
}
impl Trailer {
/// set gives the trailer its value. A value set after the body ended is
/// never sent.
pub fn set(&self, value: &str) -> Result<(), Error> {
let value = HeaderValue::from_str(value)
.map_err(|_| Error::broke(format!("{value:?} is not a header value")))?;
lock(&self.out).trailers.insert(self.name.clone(), value);
Ok(())
}
}
/// lock takes the answer's state. Nothing holds it across an await or a call
/// out, so a poisoned lock is one a panic left mid-insert, and the map is still
/// a map.
pub(crate) fn lock(out: &Mutex<Out>) -> MutexGuard<'_, Out> {
out.lock().unwrap_or_else(|p| p.into_inner())
}
fn pair(name: &str, value: &str) -> Result<(HeaderName, HeaderValue), Error> {
let n = HeaderName::from_bytes(name.as_bytes())
.map_err(|_| Error::broke(format!("{name:?} is not a header name")))?;
let v = HeaderValue::from_str(value)
.map_err(|_| Error::broke(format!("{value:?} is not a header value")))?;
Ok((n, v))
}
+7 -9
View File
@@ -65,13 +65,14 @@ pub enum Scalar {
Body,
}
/// Wire is a type that says what it is and carries itself over JSON.
/// Wire is a type that says what it is.
///
/// Two halves of one fact. `describe` is what every projection reads — the
/// field names, the URL names, the prose — and `write_json`/`read_json` are the
/// bytes those names describe. They are derived together from one declaration,
/// which is what stops a document describing a field the decoder reads under
/// another name.
/// `describe` is what the binder reads — the field names, the URL names, the
/// headers — and `stated` is the same description as one manifest entry, for
/// the projector. The bytes themselves are serde's: the derive reads the
/// `#[serde(rename)]`, `rename_all` and `skip` a type already carries, so the
/// name a document publishes for a field is the name serde reads it under, and
/// there is one place either is written down.
pub trait Wire: Sized {
fn describe() -> &'static TypeDesc;
@@ -87,7 +88,4 @@ pub trait Wire: Sized {
/// A macro sees only the item it is on, so the graph is walked by the types
/// themselves: each one claims its entry, then asks the types of its fields.
fn reach(into: &mut Vec<(&'static str, &'static str)>);
fn write_json(&self, out: &mut String);
fn read_json(v: &crate::Json) -> Result<Self, crate::Error>;
}
+20 -9
View File
@@ -42,19 +42,22 @@ impl Error {
}
/// problem is the body a refusal travels in — RFC 9457, the same shape the
/// Go side answers with, so one client reads both.
/// Go side answers with, so one client reads both. Its members are written
/// in name order, which is the order Go's encoder writes a map in.
pub fn problem(&self) -> String {
let mut out = String::from("{\"detail\":");
crate::json::write_str(&mut out, &self.detail);
out.push_str(",\"status\":");
out.push_str(&self.status.to_string());
out.push_str(",\"title\":");
crate::json::write_str(&mut out, crate::http::reason(self.status));
out.push_str(",\"type\":\"about:blank\"}");
out
format!(
"{{\"detail\":{},\"status\":{},\"title\":{},\"type\":\"about:blank\"}}",
quote(&self.detail),
self.status,
quote(reason(self.status)),
)
}
}
fn quote(s: &str) -> String {
serde_json::to_string(s).expect("a string always encodes")
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.status, self.detail)
@@ -62,3 +65,11 @@ impl fmt::Display for Error {
}
impl std::error::Error for Error {}
/// reason is the phrase beside a status.
pub fn reason(status: u16) -> &'static str {
hyper::StatusCode::from_u16(status)
.ok()
.and_then(|s| s.canonical_reason())
.unwrap_or("Unknown")
}
+182 -117
View File
@@ -2,137 +2,202 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The HTTP door.
//! The HTTP door: hyper on tokio.
//!
//! HTTP/1.1 over a socket, one thread per connection, and nothing else — no
//! executor to choose, no runtime to agree on, and therefore no dependency a
//! service inherits from its framework. A handler is a plain function; how a
//! deployment schedules them is the deployment's business.
//! HTTP/1.1 with keep-alive, and HTTP/2 over cleartext (prior knowledge) on the
//! same port: the connection's first bytes say which, so a client never has to
//! be told. A request is answered on its connection's task, and its body goes
//! out as the op produces it — each event of a stream is written and flushed
//! when it is yielded, not when a buffer fills.
//!
//! When a client goes away mid-answer, hyper drops the answer's body, and the
//! op's stream with it: over HTTP/1.1 on the read that sees the connection
//! close, over HTTP/2 on the stream's reset.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::convert::Infallible;
use std::future::Future;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Duration;
use crate::{App, Error};
use bytes::Bytes;
use futures_util::StreamExt;
use http_body_util::BodyExt;
use hyper::body::{Frame, Incoming, SizeHint};
use hyper::service::service_fn;
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use hyper_util::server::conn::auto;
use hyper_util::server::graceful::GracefulShutdown;
use tokio::net::TcpListener;
use tokio::sync::watch;
/// listen serves app over HTTP at addr, and does not return.
pub fn listen(app: Arc<App>, addr: &str) -> std::io::Result<()> {
let door = TcpListener::bind(addr)?;
for conn in door.incoming() {
let Ok(conn) = conn else { continue };
use crate::app::{trailers, Answer, Asked, Payload};
use crate::cx::Out;
use crate::{App, Body, Error};
/// serve answers HTTP on listener until `shutdown` resolves, then stops
/// accepting, lets every connection finish what it is answering, and returns
/// once they have — or once `grace` has passed, dropping what is left.
pub async fn serve<F>(
app: Arc<App>,
listener: TcpListener,
shutdown: F,
grace: Duration,
) -> std::io::Result<()>
where
F: Future<Output = ()> + Send,
{
let mut builder = auto::Builder::new(TokioExecutor::new());
// A timer turns on hyper's header read timeout, which is what keeps a
// client that opens a connection and never finishes a request from
// holding it forever.
builder.http1().timer(TokioTimer::new());
builder.http2().timer(TokioTimer::new());
let graceful = GracefulShutdown::new();
// kill ends every connection still open when the grace runs out: its task
// drops it, and with it whatever answer it was still producing.
let (kill, killed) = watch::channel(false);
tokio::pin!(shutdown);
loop {
let (stream, peer) = tokio::select! {
conn = listener.accept() => match conn {
Ok(c) => c,
// Out of descriptors, or a connection reset before it was
// taken: the listener is fine, and the next accept may not be.
Err(_) => {
tokio::time::sleep(Duration::from_millis(10)).await;
continue;
}
},
() = &mut shutdown => break,
};
let _ = stream.set_nodelay(true);
let app = Arc::clone(&app);
std::thread::spawn(move || {
let _ = serve(&app, conn);
let svc = service_fn(move |req| respond(Arc::clone(&app), peer, req));
let conn = builder
.serve_connection(TokioIo::new(stream), svc)
.into_owned();
let conn = graceful.watch(conn);
let mut killed = killed.clone();
tokio::spawn(async move {
tokio::select! {
_ = conn => {}
_ = killed.wait_for(|k| *k) => {}
}
});
}
drop(listener);
tokio::select! {
() = graceful.shutdown() => {}
() = tokio::time::sleep(grace) => {
let _ = kill.send(true);
}
}
Ok(())
}
/// serve answers every request on one connection, until the peer stops asking.
fn serve(app: &App, conn: TcpStream) -> std::io::Result<()> {
let mut out = conn.try_clone()?;
let mut r = BufReader::new(conn);
loop {
let Some((method, target, headers)) = head(&mut r)? else {
return Ok(());
};
let mut body = Vec::new();
if let Some(n) = length(&headers) {
body.resize(n, 0);
r.read_exact(&mut body)?;
}
let close = headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close"));
let answer = app.answer(&method, &target, headers, body);
write(&mut out, answer.status, answer.kind, &answer.body, close)?;
if close {
return Ok(());
}
}
}
/// head reads the request line and the headers, or None at end of stream.
type Head = Option<(String, String, Vec<(String, String)>)>;
fn head(r: &mut BufReader<TcpStream>) -> std::io::Result<Head> {
let mut line = String::new();
if r.read_line(&mut line)? == 0 {
return Ok(None);
}
let mut words = line.split_whitespace();
let (Some(method), Some(target)) = (words.next(), words.next()) else {
return Ok(None);
};
let (method, target) = (method.to_string(), target.to_string());
let mut headers = Vec::new();
loop {
let mut line = String::new();
if r.read_line(&mut line)? == 0 {
break;
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some((k, v)) = line.split_once(':') {
headers.push((k.trim().to_string(), v.trim().to_string()));
}
}
Ok(Some((method, target, headers)))
}
fn length(headers: &[(String, String)]) -> Option<usize> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.and_then(|(_, v)| v.parse().ok())
}
fn write(
out: &mut TcpStream,
status: u16,
kind: &str,
body: &[u8],
close: bool,
) -> std::io::Result<()> {
let mut head = format!(
"HTTP/1.1 {status} {}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n",
reason(status),
body.len()
async fn respond(
app: Arc<App>,
peer: SocketAddr,
req: hyper::Request<Incoming>,
) -> Result<hyper::Response<Flow>, Infallible> {
let (parts, incoming) = req.into_parts();
let target = parts
.uri
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string();
let body = Body::new(
incoming
.into_data_stream()
.map(|c| c.map_err(|e| Error::bad(format!("the request body broke: {e}")))),
);
head.push_str(if close {
"Connection: close\r\n\r\n"
} else {
"\r\n"
});
out.write_all(head.as_bytes())?;
out.write_all(body)?;
out.flush()
let answer = app
.answer(Asked {
method: parts.method.as_str().to_string(),
target,
headers: parts.headers,
peer: Some(peer),
body,
})
.await;
Ok(response(answer))
}
/// reason is the phrase beside a status. Only the ones this framework answers
/// with: a phrase nothing sends is a phrase nobody reads.
pub fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
422 => "Unprocessable Content",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "OK",
fn response(answer: Answer) -> hyper::Response<Flow> {
let status = hyper::StatusCode::from_u16(answer.status)
.unwrap_or(hyper::StatusCode::INTERNAL_SERVER_ERROR);
let mut res = hyper::Response::new(Flow {
payload: Some(answer.payload),
trailers: answer.trailers,
});
*res.status_mut() = status;
*res.headers_mut() = answer.headers;
res
}
/// Flow is an answer's body as hyper polls it: the bytes, then the trailers.
pub(crate) struct Flow {
payload: Option<Payload>,
trailers: Option<Arc<Mutex<Out>>>,
}
impl Flow {
/// end is the frame after the last chunk: the trailers, if the op set any.
fn end(&mut self) -> Option<Result<Frame<Bytes>, Error>> {
self.payload = None;
let set = trailers(self.trailers.take()?.as_ref())?;
Some(Ok(Frame::trailers(set)))
}
}
/// refuse is what a door writes when it cannot even read the request.
pub fn refuse(e: &Error) -> Vec<u8> {
e.problem().into_bytes()
impl hyper::body::Body for Flow {
type Data = Bytes;
type Error = Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Error>>> {
let this = &mut *self;
match &mut this.payload {
None => Poll::Ready(None),
Some(Payload::Full(b)) => {
if !b.is_empty() {
return Poll::Ready(Some(Ok(Frame::data(std::mem::take(b)))));
}
Poll::Ready(this.end())
}
Some(Payload::Stream(s)) => match s.poll_next_unpin(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Some(Ok(b))) => Poll::Ready(Some(Ok(Frame::data(b)))),
Poll::Ready(Some(Err(e))) => {
this.payload = None;
this.trailers = None;
Poll::Ready(Some(Err(e)))
}
Poll::Ready(None) => Poll::Ready(this.end()),
},
}
}
fn is_end_stream(&self) -> bool {
self.payload.is_none() && self.trailers.is_none()
}
/// size_hint is exact for a body in hand, so it goes out with a length —
/// unless the op declared a trailer, which HTTP/1.1 carries only after a
/// chunked body.
fn size_hint(&self) -> SizeHint {
match &self.payload {
None => SizeHint::with_exact(0),
Some(Payload::Full(b)) if self.trailers.is_none() => {
SizeHint::with_exact(b.len() as u64)
}
_ => SizeHint::default(),
}
}
}
-371
View File
@@ -1,371 +0,0 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! JSON, as far as an API needs it.
//!
//! A value that arrived and a value being written, and nothing between them: no
//! derive of its own to keep in step with [`crate::Wire`], and no second
//! annotation on a type that has already said what its fields are called. The
//! wire name a document publishes is the wire name this reads, because there is
//! one place either is written down.
use crate::Error;
use std::collections::HashMap;
/// Json is a value that arrived.
#[derive(Clone, Debug, PartialEq)]
pub enum Json {
Null,
Bool(bool),
Number(f64),
Text(String),
List(Vec<Json>),
Map(HashMap<String, Json>),
}
impl Json {
/// get is the named member of an object, or None.
pub fn get(&self, name: &str) -> Option<&Json> {
match self {
Json::Map(m) => m.get(name),
_ => None,
}
}
pub fn is_null(&self) -> bool {
matches!(self, Json::Null)
}
/// text is this value as a string, refusing anything else.
pub fn text(&self) -> Result<&str, Error> {
match self {
Json::Text(s) => Ok(s),
_ => Err(Error::bad("expected a string")),
}
}
/// parse reads one value. Trailing bytes are a refusal: a body with a
/// second document in it is a body someone else wrote.
pub fn parse(src: &str) -> Result<Json, Error> {
let b = src.as_bytes();
let mut at = 0;
let v = value(b, &mut at)?;
skip(b, &mut at);
if at != b.len() {
return Err(Error::bad("trailing bytes after the value"));
}
Ok(v)
}
}
fn skip(b: &[u8], at: &mut usize) {
while *at < b.len() && matches!(b[*at], b' ' | b'\t' | b'\n' | b'\r') {
*at += 1;
}
}
fn value(b: &[u8], at: &mut usize) -> Result<Json, Error> {
skip(b, at);
match b.get(*at) {
None => Err(Error::bad("the value ended early")),
Some(b'{') => object(b, at),
Some(b'[') => array(b, at),
Some(b'"') => Ok(Json::Text(string(b, at)?)),
Some(b't') => word(b, at, "true", Json::Bool(true)),
Some(b'f') => word(b, at, "false", Json::Bool(false)),
Some(b'n') => word(b, at, "null", Json::Null),
_ => number(b, at),
}
}
fn word(b: &[u8], at: &mut usize, want: &str, v: Json) -> Result<Json, Error> {
if b.len() >= *at + want.len() && &b[*at..*at + want.len()] == want.as_bytes() {
*at += want.len();
return Ok(v);
}
Err(Error::bad("not a value"))
}
fn object(b: &[u8], at: &mut usize) -> Result<Json, Error> {
*at += 1;
let mut m = HashMap::new();
skip(b, at);
if b.get(*at) == Some(&b'}') {
*at += 1;
return Ok(Json::Map(m));
}
loop {
skip(b, at);
let k = string(b, at)?;
skip(b, at);
if b.get(*at) != Some(&b':') {
return Err(Error::bad("a member needs a colon"));
}
*at += 1;
m.insert(k, value(b, at)?);
skip(b, at);
match b.get(*at) {
Some(b',') => *at += 1,
Some(b'}') => {
*at += 1;
return Ok(Json::Map(m));
}
_ => return Err(Error::bad("the object ended early")),
}
}
}
fn array(b: &[u8], at: &mut usize) -> Result<Json, Error> {
*at += 1;
let mut out = Vec::new();
skip(b, at);
if b.get(*at) == Some(&b']') {
*at += 1;
return Ok(Json::List(out));
}
loop {
out.push(value(b, at)?);
skip(b, at);
match b.get(*at) {
Some(b',') => *at += 1,
Some(b']') => {
*at += 1;
return Ok(Json::List(out));
}
_ => return Err(Error::bad("the list ended early")),
}
}
}
fn string(b: &[u8], at: &mut usize) -> Result<String, Error> {
if b.get(*at) != Some(&b'"') {
return Err(Error::bad("expected a string"));
}
*at += 1;
let mut out = String::new();
while let Some(&c) = b.get(*at) {
*at += 1;
match c {
b'"' => return Ok(out),
b'\\' => {
let e = *b
.get(*at)
.ok_or_else(|| Error::bad("the string ended early"))?;
*at += 1;
match e {
b'"' => out.push('"'),
b'\\' => out.push('\\'),
b'/' => out.push('/'),
b'b' => out.push('\u{8}'),
b'f' => out.push('\u{c}'),
b'n' => out.push('\n'),
b'r' => out.push('\r'),
b't' => out.push('\t'),
b'u' => {
let n = hex(b, at)?;
// A surrogate pair is two escapes for one character, so
// the low half is read here rather than left to become a
// replacement character downstream.
let c = if (0xD800..0xDC00).contains(&n) {
if b.get(*at) == Some(&b'\\') && b.get(*at + 1) == Some(&b'u') {
*at += 2;
let lo = hex(b, at)?;
0x10000 + ((n - 0xD800) << 10) + (lo - 0xDC00)
} else {
n
}
} else {
n
};
out.push(char::from_u32(c).unwrap_or('\u{fffd}'));
}
_ => return Err(Error::bad("unknown escape")),
}
}
c if c < 0x80 => out.push(c as char),
_ => {
// A multi-byte character: take its bytes whole rather than
// one at a time, since a byte of one is not a character.
let start = *at - 1;
let mut end = *at;
while end < b.len() && b[end] & 0xC0 == 0x80 {
end += 1;
}
out.push_str(
std::str::from_utf8(&b[start..end]).map_err(|_| Error::bad("not utf-8"))?,
);
*at = end;
}
}
}
Err(Error::bad("the string ended early"))
}
fn hex(b: &[u8], at: &mut usize) -> Result<u32, Error> {
if *at + 4 > b.len() {
return Err(Error::bad("a \\u escape is four digits"));
}
let s = std::str::from_utf8(&b[*at..*at + 4]).map_err(|_| Error::bad("not utf-8"))?;
*at += 4;
u32::from_str_radix(s, 16).map_err(|_| Error::bad("a \\u escape is four hex digits"))
}
fn number(b: &[u8], at: &mut usize) -> Result<Json, Error> {
let start = *at;
while let Some(&c) = b.get(*at) {
if c.is_ascii_digit() || matches!(c, b'-' | b'+' | b'.' | b'e' | b'E') {
*at += 1;
} else {
break;
}
}
let s = std::str::from_utf8(&b[start..*at]).map_err(|_| Error::bad("not utf-8"))?;
s.parse::<f64>()
.map(Json::Number)
.map_err(|_| Error::bad("not a number"))
}
// ---- writing ---------------------------------------------------------------
/// write_str appends one JSON string.
pub fn write_str(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
/// write_float appends a number. A value JSON cannot hold is written as null,
/// which is the only true thing to say about it.
pub fn write_float(out: &mut String, v: f64) {
if !v.is_finite() {
out.push_str("null");
return;
}
if v == v.trunc() && v.abs() < 1e15 {
out.push_str(&format!("{}", v as i64));
return;
}
out.push_str(&format!("{v}"));
}
/// write_bytes appends a byte string, base64 as JSON carries one.
pub fn write_bytes(out: &mut String, b: &[u8]) {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
for c in b.chunks(3) {
let n = (c[0] as u32) << 16
| (*c.get(1).unwrap_or(&0) as u32) << 8
| (*c.get(2).unwrap_or(&0) as u32);
s.push(A[(n >> 18 & 63) as usize] as char);
s.push(A[(n >> 12 & 63) as usize] as char);
s.push(if c.len() > 1 {
A[(n >> 6 & 63) as usize] as char
} else {
'='
});
s.push(if c.len() > 2 {
A[(n & 63) as usize] as char
} else {
'='
});
}
write_str(out, &s);
}
/// read_bytes is base64, back.
pub fn read_bytes(s: &str) -> Result<Vec<u8>, Error> {
let mut out = Vec::new();
let mut buf = 0u32;
let mut have = 0;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' | b'\n' | b'\r' => continue,
_ => return Err(Error::bad("not base64")),
} as u32;
buf = buf << 6 | v;
have += 6;
if have >= 8 {
have -= 8;
out.push((buf >> have) as u8);
}
}
Ok(out)
}
// ---- what a derived reader asks -------------------------------------------
//
// A field the body did not carry reads as its zero rather than as a refusal:
// that is what every other decoder on this wire does, and a required field is
// refused by validation, which is one rule in one place.
pub fn as_text(v: Option<&Json>) -> Result<String, Error> {
match v {
Some(Json::Text(s)) => Ok(s.clone()),
Some(Json::Null) | None => Ok(String::new()),
Some(_) => Err(Error::bad("expected a string")),
}
}
pub fn as_bool(v: Option<&Json>) -> Result<bool, Error> {
match v {
Some(Json::Bool(b)) => Ok(*b),
Some(Json::Null) | None => Ok(false),
Some(_) => Err(Error::bad("expected a boolean")),
}
}
pub fn as_number(v: Option<&Json>) -> Result<f64, Error> {
match v {
Some(Json::Number(n)) => Ok(*n),
Some(Json::Null) | None => Ok(0.0),
Some(_) => Err(Error::bad("expected a number")),
}
}
pub fn as_bytes(v: Option<&Json>) -> Result<Vec<u8>, Error> {
match v {
Some(Json::Text(s)) => read_bytes(s),
Some(Json::Null) | None => Ok(Vec::new()),
Some(_) => Err(Error::bad("expected base64")),
}
}
pub fn as_list(v: Option<&Json>) -> Result<&[Json], Error> {
match v {
Some(Json::List(l)) => Ok(l),
Some(Json::Null) | None => Ok(&[]),
Some(_) => Err(Error::bad("expected a list")),
}
}
/// EMPTY is what a map field reads as when the body did not carry one.
static EMPTY: std::sync::OnceLock<HashMap<String, Json>> = std::sync::OnceLock::new();
pub fn as_map(v: Option<&Json>) -> Result<&HashMap<String, Json>, Error> {
match v {
Some(Json::Map(m)) => Ok(m),
Some(Json::Null) | None => Ok(EMPTY.get_or_init(HashMap::new)),
Some(_) => Err(Error::bad("expected an object")),
}
}
/// as_wire reads a value that describes itself.
pub fn as_wire<T: crate::Wire>(v: Option<&Json>) -> Result<T, Error> {
T::read_json(v.unwrap_or(&Json::Null))
}
+92 -31
View File
@@ -10,25 +10,41 @@
//! above it. Nothing is declared twice.
//!
//! ```ignore
//! #[zip::ops(app = "info", title = "Lux node info", version = "1.0.0")]
//! impl Info {
//! /// Bootstrapped reports whether a chain has finished bootstrapping on this node.
//! #[zip::ops(app = "chat", title = "Chat", version = "1.0.0")]
//! impl Chat {
//! /// Echo answers the text it was sent.
//! ///
//! /// Example: {"chain": "X"}
//! /// Response: {"isBootstrapped": true}
//! #[get("/chain/bootstrapped")]
//! fn bootstrapped(&self, arg: &BootstrappedArgs) -> Result<Bootstrapped, zip::Error> {
//! Ok(Bootstrapped { is_bootstrapped: true })
//! /// Example: {"text": "hi"}
//! /// Response: {"text": "hi"}
//! #[post("/v1/echo")]
//! async fn echo(&self, arg: &Ping) -> Result<Pong, zip::Error> {
//! Ok(Pong { text: arg.text.clone() })
//! }
//!
//! /// Complete streams a completion, one event a token.
//! #[post("/v1/complete")]
//! async fn complete(&self, cx: &zip::Cx, arg: &Prompt) -> Result<zip::Sse, zip::Error> {
//! let tokens = cx.trailer("x-tokens")?;
//! Ok(zip::Sse::new(self.model.stream(arg, tokens)).keep(Duration::from_secs(15)))
//! }
//! }
//! ```
//!
//! An op is an `async fn` taking `&self`, optionally `&zip::Cx` (the request's
//! headers, and the answer's status, headers and trailers), and at most one
//! input: a type deriving [Wire] and serde's `Deserialize`, bound from the body,
//! the query, the path and the headers it names, or a [Body], the request body
//! as it arrives. It answers `Result<T, zip::Error>` where T is a type deriving
//! [Wire] and `Serialize` (one JSON value), `()` (204), [Sse] (an event stream)
//! or [Body] (a byte stream).
//!
//! # Where the documents come from
//!
//! The macro writes what it read — the ops, the types, the prose — as the crate
//! compiles. `zipc` projects that description into the OpenAPI document, the MCP
//! tool list, the CLI and the .zap schema, and `zapgen` compiles the schema into
//! the zero-copy accessors this crate's own wire is built from.
//! tool list, the CLI and the .zap schema. A streamed answer is part of what it
//! read: the manifest says `"stream": "sse"` or `"bytes"`, and the document
//! publishes `text/event-stream` or `application/octet-stream` for it.
//!
//! The projector is one program for all three languages, which is the whole
//! point: an operation carries one id, one summary and one schema whether it was
@@ -37,26 +53,31 @@
//! are Rust here, all the way down. No Go runs in a service built with this.
mod app;
mod bind;
mod body;
mod cx;
mod desc;
mod error;
pub mod json;
pub mod base64;
pub mod http;
pub mod zap;
pub mod zaphttp;
// zap is the ZAP wire and wire is the ZAP-HTTP frames, both generated from
// wire.zap by zapgen. They are the only wire code here, and none of it is
// written by hand.
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub use app::{bind, Answer, App, Call, Input, Op};
pub use app::{App, Op};
pub use bind::{bind, value, MAX_BODY};
pub use body::{Body, Event, Reply, Sse};
pub use cx::{Cx, Trailer};
pub use desc::{FieldDesc, Scalar, TypeDesc, Wire};
pub use error::Error;
pub use json::Json;
/// The two macros. `ops` declares a service; `Wire` makes a type say what it is.
pub use zip_macros::{ops, Wire};
/// serde is the serde zip reads and writes with, so generated code names one
/// serde whatever a service depends on.
pub use serde;
/// manifest is this service's whole description, as one document.
///
/// The ops half comes from the macro that read the impl block; the types half
@@ -83,33 +104,73 @@ pub fn manifest(ops: &str, types: &[(&str, &str)]) -> String {
out
}
/// listen serves an app on every address given, and does not return.
/// listen serves an app on every address given until `shutdown` resolves,
/// then drains both doors for at most `grace` and returns.
///
/// One verb, whatever the transport: an address decides which door it is, so a
/// binary states all of its listeners the same way and a deployment changes one
/// without touching the service.
///
/// ```text
/// info serve --zap :9653 --http :8000
/// info --zap :9653 --http :8000
/// ```
pub fn listen(app: App, http: Option<&str>, zap: Option<&str>) -> std::io::Result<()> {
pub async fn listen<F>(
app: App,
http: Option<&str>,
zap: Option<&str>,
shutdown: F,
grace: std::time::Duration,
) -> std::io::Result<()>
where
F: std::future::Future<Output = ()> + Send,
{
let app = std::sync::Arc::new(app);
let mut doors = Vec::new();
let (stop, stopped) = tokio::sync::watch::channel(false);
let mut doors = tokio::task::JoinSet::new();
let door = |mut stopped: tokio::sync::watch::Receiver<bool>| async move {
let _ = stopped.wait_for(|s| *s).await;
};
if let Some(addr) = zap {
let listener = tokio::net::TcpListener::bind(addr).await?;
let app = std::sync::Arc::clone(&app);
let addr = addr.to_string();
doors.push(std::thread::spawn(move || zaphttp::listen(app, &addr)));
let stopped = door(stopped.clone());
doors.spawn(async move { zaphttp::serve(app, listener, stopped, grace).await });
}
if let Some(addr) = http {
let listener = tokio::net::TcpListener::bind(addr).await?;
let app = std::sync::Arc::clone(&app);
let addr = addr.to_string();
doors.push(std::thread::spawn(move || http::listen(app, &addr)));
let stopped = door(stopped.clone());
doors.spawn(async move { http::serve(app, listener, stopped, grace).await });
}
for door in doors {
match door.join() {
Ok(r) => r?,
Err(_) => return Err(std::io::Error::other("a door stopped")),
}
shutdown.await;
let _ = stop.send(true);
while let Some(done) = doors.join_next().await {
done.map_err(std::io::Error::other)??;
}
Ok(())
}
/// signal resolves when the process is asked to stop: SIGINT or SIGTERM, the
/// second being what a pod is sent.
pub async fn signal() {
let interrupt = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut term =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(t) => t,
Err(_) => {
let _ = interrupt.await;
return;
}
};
tokio::select! {
_ = interrupt => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = interrupt.await;
}
}
+385
View File
@@ -0,0 +1,385 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The ZAP-HTTP frames: HTTP semantics on the ZAP wire, as zap-proto/http
//! (Go) writes them.
//!
//! A frame is a ZAP message: a 16-byte header (magic `ZAP\0`, version 1, flags
//! carrying the frame type in their upper byte, the root object's offset, the
//! message's size), then the root object's fixed slots, then the bytes the
//! slots point at. A text or bytes slot is eight bytes, a little-endian offset
//! relative to the slot and a length; an empty one is all zero.
//!
//! | frame | type | slots |
//! |---|---|---|
//! | request | 0x01 | method @0, target @8, proto @16, headers @24, body @32, trailer @40 |
//! | response | 0x02 | status u16 @0, reason @8, proto @16, headers @24, body @32, trailer @40 |
//! | streamed head | 0x03 | a response with no body and no trailer |
//! | data | 0x04 | one chunk of the body @0 |
//! | end | 0x05 | the trailer pairs @0 |
//!
//! Headers and trailers ride as a count, then that many length-prefixed name
//! and value runs, every integer little-endian. On a connection each frame is
//! preceded by its length as a BIG-endian u32, as zap-proto/http's transport
//! writes it.
//!
//! The offsets are zap-proto/http's (wire.go). No zapgen emits Rust, so they
//! are written here once and held to frames Go wrote, byte for byte, by the
//! tests at the bottom.
use bytes::Bytes;
pub const FRAME_REQUEST: u16 = 0x01;
pub const FRAME_RESPONSE: u16 = 0x02;
pub const FRAME_HEAD: u16 = 0x03;
pub const FRAME_DATA: u16 = 0x04;
pub const FRAME_END: u16 = 0x05;
const HEADER: usize = 16;
const MAGIC: &[u8; 4] = b"ZAP\0";
const VERSION: u16 = 1;
/// The protocol an answer names, which is what fasthttp reports for one.
const PROTO: &str = "HTTP/1.1";
/// Request is a request frame, read where it lies.
pub struct Request<'a> {
frame: &'a [u8],
root: usize,
size: usize,
}
impl<'a> Request<'a> {
pub fn wrap(frame: &'a [u8]) -> Option<Request<'a>> {
let (root, size) = root(frame, FRAME_REQUEST)?;
Some(Request { frame, root, size })
}
pub fn method(&self) -> &'a str {
text(var(self.frame, self.size, self.root, 0))
}
pub fn target(&self) -> &'a str {
text(var(self.frame, self.size, self.root, 8))
}
pub fn headers(&self) -> &'a [u8] {
var(self.frame, self.size, self.root, 24)
}
pub fn body(&self) -> &'a [u8] {
var(self.frame, self.size, self.root, 32)
}
}
/// Response is a response frame or a streamed head, read where it lies.
pub struct Response<'a> {
frame: &'a [u8],
root: usize,
size: usize,
}
impl<'a> Response<'a> {
pub fn wrap(frame: &'a [u8]) -> Option<Response<'a>> {
let (root, size) = root(frame, FRAME_RESPONSE).or_else(|| root(frame, FRAME_HEAD))?;
Some(Response { frame, root, size })
}
pub fn status(&self) -> u16 {
let at = self.root;
if at + 2 > self.size {
return 0;
}
u16::from_le_bytes([self.frame[at], self.frame[at + 1]])
}
pub fn headers(&self) -> &'a [u8] {
var(self.frame, self.size, self.root, 24)
}
pub fn body(&self) -> &'a [u8] {
var(self.frame, self.size, self.root, 32)
}
pub fn trailer(&self) -> &'a [u8] {
var(self.frame, self.size, self.root, 40)
}
}
/// kind is a frame's type, from its header alone.
pub fn kind(frame: &[u8]) -> Option<u16> {
if frame.len() < HEADER || &frame[0..4] != MAGIC {
return None;
}
Some(u16::from_le_bytes([frame[6], frame[7]]) >> 8)
}
/// chunk is a data or end frame's one slot.
pub fn chunk(frame: &[u8]) -> Option<&[u8]> {
let (root, size) = root(frame, FRAME_DATA).or_else(|| root(frame, FRAME_END))?;
Some(var(frame, size, root, 0))
}
/// root checks a frame's header — magic, version, size, type — and answers its
/// root object's offset and the message's length. Bytes past the size are not
/// the message's.
fn root(frame: &[u8], want: u16) -> Option<(usize, usize)> {
if kind(frame)? != want {
return None;
}
let version = u16::from_le_bytes([frame[4], frame[5]]);
if version != 1 && version != 2 {
return None;
}
let size = u32_at(frame, 12) as usize;
if size < HEADER || size > frame.len() {
return None;
}
let root = u32_at(frame, 8) as usize;
if root < HEADER || root >= size {
return None;
}
Some((root, size))
}
/// var is the bytes one slot points at, or none when the slot is empty or
/// points anywhere it may not: before the data, or past the message.
fn var(frame: &[u8], size: usize, root: usize, field: usize) -> &[u8] {
let slot = root + field;
if slot + 8 > size {
return &[];
}
let rel = u32_at(frame, slot) as usize;
if rel == 0 {
return &[];
}
let len = u32_at(frame, slot + 4) as usize;
let Some(at) = slot.checked_add(rel) else {
return &[];
};
match at.checked_add(len) {
Some(end) if at >= HEADER && end <= size => &frame[at..end],
_ => &[],
}
}
fn text(b: &[u8]) -> &str {
std::str::from_utf8(b).unwrap_or("")
}
fn u32_at(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
/// pairs walks a header block. Every name and value is a slice of the frame; a
/// block that runs short ends where it ran short.
pub fn pairs(raw: &[u8]) -> Vec<(&[u8], &[u8])> {
let mut out = Vec::new();
if raw.len() < 4 {
return out;
}
let count = u32_at(raw, 0) as usize;
let mut at = 4;
for _ in 0..count {
let Some(name) = run(raw, &mut at) else { break };
let Some(value) = run(raw, &mut at) else {
break;
};
out.push((name, value));
}
out
}
fn run<'a>(raw: &'a [u8], at: &mut usize) -> Option<&'a [u8]> {
if *at + 4 > raw.len() {
return None;
}
let n = u32_at(raw, *at) as usize;
*at += 4;
let end = at.checked_add(n)?;
if end > raw.len() {
return None;
}
let s = &raw[*at..end];
*at = end;
Some(s)
}
/// block writes header pairs as a header block; no pairs is no block.
pub fn block<'a>(pairs: impl IntoIterator<Item = (&'a [u8], &'a [u8])>) -> Vec<u8> {
let mut out = vec![0, 0, 0, 0];
let mut n = 0u32;
for (name, value) in pairs {
out.extend_from_slice(&(name.len() as u32).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&(value.len() as u32).to_le_bytes());
out.extend_from_slice(value);
n += 1;
}
if n == 0 {
return Vec::new();
}
out[0..4].copy_from_slice(&n.to_le_bytes());
out
}
/// Builder lays one frame out: the header, the fixed slots, then each field's
/// bytes in the order they are put.
struct Builder {
buf: Vec<u8>,
}
impl Builder {
fn new(kind: u16, slots: usize, room: usize) -> Builder {
let mut buf = Vec::with_capacity(HEADER + slots + room);
buf.extend_from_slice(MAGIC);
buf.extend_from_slice(&VERSION.to_le_bytes());
buf.extend_from_slice(&(kind << 8).to_le_bytes());
buf.extend_from_slice(&(HEADER as u32).to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.resize(HEADER + slots, 0);
Builder { buf }
}
fn u16(&mut self, field: usize, v: u16) {
let at = HEADER + field;
self.buf[at..at + 2].copy_from_slice(&v.to_le_bytes());
}
fn put(&mut self, field: usize, data: &[u8]) {
if data.is_empty() {
return;
}
let slot = HEADER + field;
let rel = (self.buf.len() - slot) as u32;
self.buf.extend_from_slice(data);
self.buf[slot..slot + 4].copy_from_slice(&rel.to_le_bytes());
self.buf[slot + 4..slot + 8].copy_from_slice(&(data.len() as u32).to_le_bytes());
}
fn finish(mut self) -> Vec<u8> {
let size = self.buf.len() as u32;
self.buf[12..16].copy_from_slice(&size.to_le_bytes());
self.buf
}
}
/// request builds a request frame: what a client sends.
pub fn request(method: &str, target: &str, headers: &[u8], body: &[u8]) -> Vec<u8> {
let mut b = Builder::new(
FRAME_REQUEST,
48,
method.len() + target.len() + headers.len() + body.len() + 8,
);
b.put(0, method.as_bytes());
b.put(8, target.as_bytes());
b.put(16, PROTO.as_bytes());
b.put(24, headers);
b.put(32, body);
b.finish()
}
/// response builds an answer frame, whole.
pub fn response(status: u16, headers: &[u8], body: &[u8], trailer: &[u8]) -> Vec<u8> {
let mut b = Builder::new(
FRAME_RESPONSE,
48,
headers.len() + body.len() + trailer.len() + 32,
);
b.u16(0, status);
b.put(8, crate::error::reason(status).as_bytes());
b.put(16, PROTO.as_bytes());
b.put(24, headers);
b.put(32, body);
b.put(40, trailer);
b.finish()
}
/// head builds the opening frame of a streamed answer: its status and headers.
pub fn head(status: u16, headers: &[u8]) -> Vec<u8> {
let mut b = Builder::new(FRAME_HEAD, 48, headers.len() + 32);
b.u16(0, status);
b.put(8, crate::error::reason(status).as_bytes());
b.put(16, PROTO.as_bytes());
b.put(24, headers);
b.finish()
}
/// data builds one chunk of a streamed body.
pub fn data(chunk: &Bytes) -> Vec<u8> {
let mut b = Builder::new(FRAME_DATA, 8, chunk.len());
b.put(0, chunk);
b.finish()
}
/// end builds the frame that ends a streamed body, carrying its trailers.
pub fn end(trailer: &[u8]) -> Vec<u8> {
let mut b = Builder::new(FRAME_END, 8, trailer.len());
b.put(0, trailer);
b.finish()
}
#[cfg(test)]
mod tests {
use super::*;
// What zap-proto/http v0.3.12 writes (MarshalRequest, MarshalResponse,
// MarshalResponseHead, MarshalData, MarshalEnd) for the same messages.
const GO_REQUEST: &str = "5a41500001000001100000007d00000030000000040000002c0000000c00000030000000080000003000000018000000400000000d0000000000000000000000504f53542f76312f6563686f3f783d31485454502f312e310100000008000000582d4f72672d49640400000061636d657b2274657874223a226869227d";
const GO_RESPONSE: &str = "5a41500001000002100000007f000000c800000000000000280000000200000022000000080000002200000028000000420000000d00000000000000000000004f4b485454502f312e31010000000c000000436f6e74656e742d54797065100000006170706c69636174696f6e2f6a736f6e7b2274657874223a226869227d";
const GO_HEAD: &str = "5a415000010000031000000073000000c800000000000000280000000200000022000000080000002200000029000000000000000000000000000000000000004f4b485454502f312e31010000000c000000436f6e74656e742d5479706511000000746578742f6576656e742d73747265616d";
const GO_DATA: &str = "5a4150000100000410000000210000000800000009000000646174613a20310a0a";
const GO_END: &str = "5a4150000100000510000000180000000000000000000000";
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn frames_are_the_ones_go_writes() {
let h = block([(&b"X-Org-Id"[..], &b"acme"[..])]);
assert_eq!(
hex(&request("POST", "/v1/echo?x=1", &h, br#"{"text":"hi"}"#)),
GO_REQUEST
);
let h = block([(&b"Content-Type"[..], &b"application/json"[..])]);
assert_eq!(
hex(&response(200, &h, br#"{"text":"hi"}"#, &[])),
GO_RESPONSE
);
let h = block([(&b"Content-Type"[..], &b"text/event-stream"[..])]);
assert_eq!(hex(&head(200, &h)), GO_HEAD);
assert_eq!(hex(&data(&Bytes::from_static(b"data: 1\n\n"))), GO_DATA);
assert_eq!(hex(&end(&[])), GO_END);
}
#[test]
fn a_go_request_reads() {
let f = unhex(GO_REQUEST);
let r = Request::wrap(&f).unwrap();
assert_eq!(r.method(), "POST");
assert_eq!(r.target(), "/v1/echo?x=1");
assert_eq!(pairs(r.headers()), vec![(&b"X-Org-Id"[..], &b"acme"[..])]);
assert_eq!(r.body(), br#"{"text":"hi"}"#);
assert!(Response::wrap(&f).is_none(), "a request is not an answer");
let f = unhex(GO_HEAD);
assert_eq!(Response::wrap(&f).unwrap().status(), 200);
assert_eq!(chunk(&unhex(GO_DATA)).unwrap(), b"data: 1\n\n");
assert_eq!(kind(&unhex(GO_END)), Some(FRAME_END));
}
#[test]
fn a_crooked_frame_reads_as_nothing() {
let mut f = unhex(GO_REQUEST);
// A slot pointing past the message.
f[HEADER + 8] = 0xff;
let r = Request::wrap(&f).unwrap();
assert_eq!(r.target(), "");
assert!(Request::wrap(&f[..10]).is_none());
let mut g = unhex(GO_REQUEST);
g[0] = b'X';
assert!(Request::wrap(&g).is_none());
// A header block whose count overstates what it holds.
assert!(pairs(&[9, 0, 0, 0, 1, 0, 0, 0]).is_empty());
}
}
+157 -131
View File
@@ -4,164 +4,190 @@
//! The ZAP door.
//!
//! The same requests, carried as ZAP frames instead of as text. A frame is one
//! message: a four-byte length, then a ZAP buffer whose header flags say
//! whether it is a request or an answer, and whose root object holds the method,
//! the target, the headers and the body at fixed offsets.
//!
//! Nothing here decodes. A field is a bounds-checked look at bytes already in
//! hand — `target()` is a `&str` INTO the frame — so a request arrives without
//! being copied into a shape first. What is built is built once, into one
//! buffer, by the builder the generator ships.
//!
//! The offsets are the ones the ZAP-HTTP schema states (wire.zap), and the
//! accessors that read them are generated from it. There is no hand-written
//! wire in this file.
//! The same requests, carried as ZAP frames instead of as text (see [crate::zap]
//! for the frames). A connection carries one exchange at a time: a request
//! frame in, then either one response frame or — for a streamed answer — a
//! head, a data frame per chunk as the op produces it, and an end frame with
//! the trailers. That is zap-proto/http's own shape, so a Go client reads a
//! Rust stream chunk by chunk, and a Go server's stream reads here the same way.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use crate::wire::{self, Request};
use crate::{zap, App};
use bytes::Bytes;
use futures_util::StreamExt;
use hyper::header::{self, HeaderMap, HeaderName, HeaderValue};
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, watch};
/// The frame type, carried in the ZAP header's flags. A request and an answer
/// are the two shapes this door speaks.
pub const FRAME_REQUEST: u16 = 0x01;
pub const FRAME_RESPONSE: u16 = 0x02;
use crate::app::{trailers, Answer, Asked, Payload};
use crate::{zap, App, Body, Error};
/// The largest frame this door will read. A length prefix is a promise from
/// whoever sent it, and a door that believes one has handed the peer a way to
/// ask for all the memory there is.
/// MAX_FRAME is the largest frame this door reads. A length prefix is a
/// promise from whoever sent it, and a door that believes one has handed the
/// peer a way to ask for all the memory there is.
pub const MAX_FRAME: usize = 32 << 20;
/// listen serves app over ZAP at addr, and does not return.
pub fn listen(app: Arc<App>, addr: &str) -> std::io::Result<()> {
let door = TcpListener::bind(addr)?;
for conn in door.incoming() {
let Ok(conn) = conn else { continue };
/// serve answers ZAP on listener until `shutdown` resolves, then stops
/// accepting; a connection finishes the exchange it is in and closes. It
/// returns when every connection has, or once `grace` has passed, ending
/// those still open.
pub async fn serve<F>(
app: Arc<App>,
listener: TcpListener,
shutdown: F,
grace: Duration,
) -> std::io::Result<()>
where
F: Future<Output = ()> + Send,
{
let (stop, stopped) = watch::channel(false);
// kill ends every connection still open when the grace runs out.
let (kill, killed) = watch::channel(false);
// Every connection holds a sender; the receiver ends when the last one has
// gone, which is how the drain knows it is done without counting.
let (live, mut gone) = mpsc::channel::<()>(1);
tokio::pin!(shutdown);
loop {
let (stream, peer) = tokio::select! {
conn = listener.accept() => match conn {
Ok(c) => c,
Err(_) => {
tokio::time::sleep(Duration::from_millis(10)).await;
continue;
}
},
() = &mut shutdown => break,
};
let _ = stream.set_nodelay(true);
let app = Arc::clone(&app);
std::thread::spawn(move || {
let _ = serve(&app, conn);
let stopped = stopped.clone();
let mut killed = killed.clone();
let live = live.clone();
tokio::spawn(async move {
tokio::select! {
_ = connection(&app, stream, peer, stopped) => {}
_ = killed.wait_for(|k| *k) => {}
}
drop(live);
});
}
drop(listener);
let _ = stop.send(true);
drop(live);
if tokio::time::timeout(grace, gone.recv()).await.is_err() {
let _ = kill.send(true);
}
Ok(())
}
fn serve(app: &App, mut conn: TcpStream) -> std::io::Result<()> {
let mut out = conn.try_clone()?;
async fn connection(
app: &App,
stream: TcpStream,
peer: SocketAddr,
mut stopped: watch::Receiver<bool>,
) -> std::io::Result<()> {
let (read, mut write) = stream.into_split();
let mut read = BufReader::new(read);
loop {
let Some(frame) = read_frame(&mut conn)? else {
return Ok(());
let frame = tokio::select! {
f = read_frame(&mut read) => f?,
_ = stopped.wait_for(|s| *s) => return Ok(()),
};
let answer = match Request::wrap(&frame) {
Ok(r) => {
let headers = pairs(r.headers());
app.answer(r.method(), r.target(), headers, r.body().to_vec())
let Some(frame) = frame else { return Ok(()) };
let answer = match zap::Request::wrap(&frame) {
Some(r) => {
let mut headers = HeaderMap::new();
for (name, value) in zap::pairs(r.headers()) {
if let (Ok(n), Ok(v)) =
(HeaderName::from_bytes(name), HeaderValue::from_bytes(value))
{
headers.append(n, v);
}
}
let body = Body::full(Bytes::copy_from_slice(r.body()));
app.answer(Asked {
method: r.method().to_string(),
target: r.target().to_string(),
headers,
peer: Some(peer),
body,
})
.await
}
Err(_) => crate::Answer {
status: 400,
kind: "application/problem+json",
body: crate::http::refuse(&crate::Error::bad("malformed request frame")),
},
None => Answer::refusal(&Error::bad("malformed request frame"), HeaderMap::new()),
};
let body = build(answer.status, answer.kind, &answer.body);
out.write_all(&(body.len() as u32).to_le_bytes())?;
out.write_all(&body)?;
out.flush()?;
write_answer(&mut write, answer).await?;
}
}
/// read_frame takes one length-prefixed frame off the connection.
fn read_frame(conn: &mut TcpStream) -> std::io::Result<Option<Vec<u8>>> {
/// write_answer writes one answer: one frame for a body in hand, a head, data
/// frames and an end for a stream. A stream that fails ends the connection
/// without an end frame, which is what tells the peer the answer is cut.
async fn write_answer<W: AsyncWriteExt + Unpin>(w: &mut W, answer: Answer) -> std::io::Result<()> {
// Trailer is the frame's own slot on this wire, never a header.
let headers = block(&answer.headers, true);
match answer.payload {
Payload::Full(ref body) => {
let t = answer
.trailers
.as_deref()
.and_then(trailers)
.map(|t| block(&t, false))
.unwrap_or_default();
send(w, &zap::response(answer.status, &headers, body, &t)).await
}
Payload::Stream(mut chunks) => {
send(w, &zap::head(answer.status, &headers)).await?;
while let Some(chunk) = chunks.next().await {
match chunk {
Ok(c) if c.is_empty() => {}
Ok(c) => send(w, &zap::data(&c)).await?,
Err(e) => return Err(std::io::Error::other(e)),
}
}
let t = answer
.trailers
.as_deref()
.and_then(trailers)
.map(|t| block(&t, false))
.unwrap_or_default();
send(w, &zap::end(&t)).await
}
}
}
fn block(h: &HeaderMap, head: bool) -> Vec<u8> {
zap::block(
h.iter()
.filter(|(n, _)| !head || **n != header::TRAILER)
.map(|(n, v)| (n.as_str().as_bytes(), v.as_bytes())),
)
}
async fn send<W: AsyncWriteExt + Unpin>(w: &mut W, frame: &[u8]) -> std::io::Result<()> {
w.write_all(&(frame.len() as u32).to_be_bytes()).await?;
w.write_all(frame).await?;
w.flush().await
}
/// read_frame takes one length-prefixed frame off the connection, or None at
/// its end. A frame of no bytes or past MAX_FRAME ends the connection.
pub async fn read_frame<R: AsyncReadExt + Unpin>(r: &mut R) -> std::io::Result<Option<Vec<u8>>> {
let mut head = [0u8; 4];
match conn.read_exact(&mut head) {
Ok(()) => {}
match r.read_exact(&mut head).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let n = u32::from_le_bytes(head) as usize;
let n = u32::from_be_bytes(head) as usize;
if n == 0 || n > MAX_FRAME {
return Ok(None);
}
let mut frame = vec![0u8; n];
conn.read_exact(&mut frame)?;
r.read_exact(&mut frame).await?;
Ok(Some(frame))
}
/// pairs walks the header block: a count, then that many length-prefixed name
/// and value runs. Every name and value is a slice of the frame.
fn pairs(raw: &[u8]) -> Vec<(String, String)> {
let mut out = Vec::new();
if raw.len() < 4 {
return out;
}
let count = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]) as usize;
let mut at = 4;
for _ in 0..count {
let Some(name) = run(raw, &mut at) else { break };
let Some(value) = run(raw, &mut at) else {
break;
};
out.push((
String::from_utf8_lossy(name).into_owned(),
String::from_utf8_lossy(value).into_owned(),
));
}
out
}
fn run<'a>(raw: &'a [u8], at: &mut usize) -> Option<&'a [u8]> {
if *at + 4 > raw.len() {
return None;
}
let n = u32::from_le_bytes([raw[*at], raw[*at + 1], raw[*at + 2], raw[*at + 3]]) as usize;
*at += 4;
if *at + n > raw.len() {
return None;
}
let s = &raw[*at..*at + n];
*at += n;
Some(s)
}
/// build writes one answer frame: the status, the content type as the one
/// header, and the body.
fn build(status: u16, kind: &str, body: &[u8]) -> Vec<u8> {
let mut headers = Vec::new();
headers.extend_from_slice(&1u32.to_le_bytes());
push(&mut headers, b"Content-Type");
push(&mut headers, kind.as_bytes());
let reason = crate::http::reason(status);
let mut b = zap::Builder::new(body.len() + headers.len() + 256);
let mut ob = b.start_object(wire::RESPONSE_SIZE);
ob.set_u16(&mut b, wire::RESPONSE_STATUS, status);
ob.set_text(&mut b, wire::RESPONSE_REASON, reason);
ob.set_text(&mut b, wire::RESPONSE_PROTO, "ZAP-HTTP/1.0");
ob.set_bytes(&mut b, wire::RESPONSE_HEADERS, &headers);
ob.set_bytes(&mut b, wire::RESPONSE_BODY, body);
ob.set_bytes(&mut b, wire::RESPONSE_TRAILER, &[]);
ob.finish_as_root(&mut b);
b.finish_with_flags(FRAME_RESPONSE << 8)
}
fn push(out: &mut Vec<u8>, s: &[u8]) {
out.extend_from_slice(&(s.len() as u32).to_le_bytes());
out.extend_from_slice(s);
}
/// ask builds one request frame — what a client sends, and what the tests read
/// back to prove the door answers ZAP and not only text.
pub fn ask(method: &str, target: &str, body: &[u8]) -> Vec<u8> {
let mut b = zap::Builder::new(body.len() + 256);
let mut ob = b.start_object(wire::REQUEST_SIZE);
ob.set_text(&mut b, wire::REQUEST_METHOD, method);
ob.set_text(&mut b, wire::REQUEST_TARGET, target);
ob.set_text(&mut b, wire::REQUEST_PROTO, "ZAP-HTTP/1.0");
ob.set_bytes(&mut b, wire::REQUEST_HEADERS, &[]);
ob.set_bytes(&mut b, wire::REQUEST_BODY, body);
ob.set_bytes(&mut b, wire::REQUEST_TRAILER, &[]);
ob.finish_as_root(&mut b);
b.finish_with_flags(FRAME_REQUEST << 8)
}
+242
View File
@@ -0,0 +1,242 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! What the derive describes, what the binder reads, and what the door does
//! with a handler that breaks.
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use zip::{Cx, Error, Wire};
/// Ask is a request whose fields arrive from everywhere a request has.
#[derive(Wire, Serialize, Deserialize, Default, Debug, PartialEq)]
#[serde(default, rename_all = "camelCase")]
struct Ask {
/// Org is read from the gateway's header.
#[zip(header = "X-Org-Id")]
org_id: String,
/// Model is required.
#[zip(required)]
model: String,
/// MaxTokens rides the query on a GET.
max_tokens: u32,
/// Stop is a list.
stop: Vec<String>,
/// Extra is passed through unread.
extra: Option<serde_json::Value>,
/// Blob is bytes, as base64.
#[serde(with = "zip::base64")]
blob: Vec<u8>,
/// Kept is never on the wire.
#[serde(skip)]
kept: u32,
/// Named is renamed outright.
#[serde(rename = "n")]
named: Count,
}
/// Count is a 64-bit count, carried as a quoted decimal.
#[derive(Wire, Default, Debug, PartialEq, Clone, Copy)]
#[zip(text)]
struct Count(u64);
struct Svc;
#[zip::ops(app = "t", title = "T", version = "1.0.0")]
impl Svc {
/// Ask answers the input it bound.
#[get("/v1/ask/:model")]
async fn ask(&self, arg: &Ask) -> Result<Ask, Error> {
Ok(Ask {
org_id: arg.org_id.clone(),
model: arg.model.clone(),
max_tokens: arg.max_tokens,
stop: arg.stop.clone(),
extra: arg.extra.clone(),
blob: arg.blob.clone(),
kept: 0,
named: arg.named,
})
}
/// Post answers its input, by value.
#[post("/v1/post")]
async fn post(&self, cx: &Cx, arg: Ask) -> Result<Ask, Error> {
cx.set_status(201)?;
cx.set_header("x-model", &arg.model)?;
Ok(arg)
}
/// Fail refuses, with a header on the refusal.
#[post("/v1/fail")]
async fn fail(&self, cx: &Cx) -> Result<(), Error> {
cx.set_header("retry-after", "7")?;
Err(Error::new(429, "slow down"))
}
/// Break panics.
#[post("/v1/break")]
async fn broken(&self) -> Result<(), Error> {
panic!("a handler bug");
}
}
fn field(name: &str) -> &'static zip::FieldDesc {
Ask::describe()
.fields
.iter()
.find(|f| f.name == name)
.unwrap()
}
#[test]
fn wire_names_are_serdes() {
assert_eq!(field("org_id").json, "orgId");
assert_eq!(field("org_id").header, "X-Org-Id");
assert_eq!(field("max_tokens").json, "maxTokens");
assert_eq!(field("kept").json, "-");
assert_eq!(field("named").json, "n");
assert!(field("model").required);
let m = Svc::manifest();
assert!(m.contains(r#""json":"maxTokens""#), "{m}");
assert!(m.contains(r#""name":"extra","json":"extra","doc":"Extra is passed through unread.","type":{"prim":"any","opt":true}"#), "{m}");
assert!(m.contains(r#""type":{"prim":"bytes"}"#), "{m}");
assert!(m.contains(r#"{"id":"Count","name":"Count","kind":"scalar","spell":"Count","repr":"u64","text":true,"json":{"type":"string"}}"#), "{m}");
}
#[test]
fn a_text_type_is_one_word() {
assert_eq!(
serde_json::to_string(&Count(18446744073709551615)).unwrap(),
"\"18446744073709551615\""
);
assert_eq!(serde_json::from_str::<Count>("\"42\"").unwrap(), Count(42));
let e = serde_json::from_str::<Count>("\"x\"")
.unwrap_err()
.to_string();
assert!(e.contains("not a Count"), "{e}");
}
async fn serve() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let app = Arc::new(Svc.ops());
tokio::spawn(zip::http::serve(
app,
listener,
std::future::pending(),
Duration::from_secs(1),
));
addr
}
/// ask sends one request on its own connection and answers the status, the
/// head and the body.
async fn ask(addr: &str, req: &str) -> (u16, String, String) {
let mut c = tokio::net::TcpStream::connect(addr).await.unwrap();
c.write_all(req.as_bytes()).await.unwrap();
let mut all = Vec::new();
c.read_to_end(&mut all).await.unwrap();
let all = String::from_utf8(all).unwrap();
let (head, body) = all.split_once("\r\n\r\n").unwrap();
let status = head.split_whitespace().nth(1).unwrap().parse().unwrap();
(status, head.to_ascii_lowercase(), body.to_string())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_request_binds_from_the_url_and_headers() {
let addr = serve().await;
let (status, _, body) = ask(
&addr,
"GET /v1/ask/kai?maxTokens=64&stop=a,b HTTP/1.1\r\nHost: x\r\nX-Org-Id: acme\r\nConnection: close\r\n\r\n",
)
.await;
assert_eq!(status, 200, "{body}");
let got: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
got,
serde_json::json!({"orgId": "acme", "model": "kai", "maxTokens": 64, "stop": ["a", "b"], "extra": null, "blob": "", "n": "0"})
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_body_binds_whole_and_the_answer_carries_what_cx_set() {
let addr = serve().await;
let body = r#"{"model":"kai","extra":{"tools":[1,2]},"blob":"emlw","n":"7","kept":9}"#;
let req = format!(
"POST /v1/post HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, head, out) = ask(&addr, &req).await;
assert_eq!(status, 201, "{out}");
assert!(head.contains("x-model: kai"), "{head}");
let got: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(got["extra"], serde_json::json!({"tools": [1, 2]}));
assert_eq!(got["blob"], "emlw");
assert_eq!(got["n"], "7");
assert!(got.get("kept").is_none(), "a skipped field rode the wire");
// Not JSON is a 400, said as a problem document.
let req =
"POST /v1/post HTTP/1.1\r\nHost: x\r\nContent-Length: 3\r\nConnection: close\r\n\r\n{x}";
let (status, head, _) = ask(&addr, req).await;
assert_eq!(status, 400);
assert!(
head.contains("content-type: application/problem+json"),
"{head}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_refusal_keeps_its_status_and_the_headers_set() {
let addr = serve().await;
let (status, head, body) = ask(
&addr,
"POST /v1/fail HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
assert_eq!(status, 429);
assert!(head.contains("retry-after: 7"), "{head}");
assert_eq!(
body,
r#"{"detail":"slow down","status":429,"title":"Too Many Requests","type":"about:blank"}"#
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_handler_that_panics_is_a_500_and_the_server_lives() {
let addr = serve().await;
let (status, _, body) = ask(
&addr,
"POST /v1/break HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
assert_eq!(status, 500);
assert!(body.contains("the handler panicked"), "{body}");
let (status, _, _) = ask(
&addr,
"GET /v1/ask/kai HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",
)
.await;
assert_eq!(status, 200);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_body_past_the_bound_is_refused_before_it_is_read() {
let addr = serve().await;
let n = zip::MAX_BODY + 1;
let (status, _, _) = ask(
&addr,
&format!(
"POST /v1/post HTTP/1.1\r\nHost: x\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n"
),
)
.await;
// Only the head was sent. A server that read before it counted would wait
// here for 32 MiB that are never coming.
assert_eq!(status, 413);
}
-29
View File
@@ -1,29 +0,0 @@
# The ZAP-HTTP frame: HTTP request and response semantics on the ZAP wire.
#
# These are the offsets zap-proto/http writes, so a Rust service and a Go one
# answer each other's frames. The accessors that read them are generated from
# this file; nothing in the runtime spells a byte offset by hand.
#
# Headers ride the frame as a count-prefixed run of length-prefixed name and
# value pairs — a repeated name is a repeated pair, which is what a multi-value
# header already is, so nothing has to model a list.
package wire
struct Request {
Method text @0
Target text @8
Proto text @16
Headers bytes @24
Body bytes @32
Trailer bytes @40
}
struct Response {
Status u16 @0
Reason text @8
Proto text @16
Headers bytes @24
Body bytes @32
Trailer bytes @40
}