zip declares its ops in Rust, and projects them with the Go one's projector

A Rust developer writes Rust handlers, with Rust types and Rust doc comments,
and gets every projection from that source — the same deal a Go developer has
had. No .zap is written by hand and no Go program declares a Rust service's
ops.

The split falls on a measured seam. What is native per language is DECLARING an
op and RUNNING it; what is shared is the projection logic, driven from a neutral
description of the registry.

  Manifest      the registry with the Go taken out: ops, types, fields, wire
                names, prose. App.Manifest fills one in by reflection; a
                proc-macro fills one in as a Rust crate compiles.
  Project*      the OpenAPI document, the MCP tool list, the CLI and the ZAP
                IDL, read off a Manifest. Go's own three now go through them —
                the whole existing suite is the oracle — so Go keeps no private
                path for them and the other languages cannot rot.
  cmd/zipc      the build-time projector. It reads a manifest, or the fragments
                a compiler wrote, and writes the four documents.
  rust/         zip in Rust: #[zip::ops] on an impl block, #[derive(zip::Wire)]
                on a type, a router, a JSON binder, an HTTP door and a ZAP door.
                The wire is generated by zapgen from wire.zap; nothing here
                spells a byte offset by hand and nothing links Go.

The layout rule moved out of the manifest and into the projector, so the offsets
in a .zap are derived once for all three languages rather than three times;
TestProjectZAPMatchesRegistry pins that derivation against the layout the Go
encoder actually writes.

One service, written twice — corpus/info in Go and rust/examples/info in Rust,
fourteen ops each — is the gate. The OpenAPI document, the MCP tool list and the
CLI command tree come out byte-identical, and the ZAP schema agrees on every
struct, type and offset; it differs only where a field carries the declaring
language's own case convention and where the ledger names a type the way the
person who must change it would grep for it.
This commit is contained in:
2026-09-15 11:41:56 -07:00
parent 1374598de6
commit fb2b7f6d5c
45 changed files with 7283 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
# What a build leaves behind. The projections zipc writes are checked in beside
# the source (rust/examples/info/gen), because they are the contract; the
# compiler's scratch is not.
rust/target/
rust/examples/*/target/
+10 -9
View File
@@ -109,16 +109,17 @@ type Flag struct {
// Commands projects every registered typed op into a command. This is the whole
// derivation: no registration, no list of commands, no per-endpoint code.
func (a *App) Commands() []Command {
ops := a.Registry()
cmds := make([]Command, 0, len(ops))
for _, op := range ops {
doc, has := docFor(op.Pkg, op.Method, op.Path)
c := newCommand(op.Method, op.Path, opName(op), op.Summary, doc, has)
c.op = op
c.Args, c.Flags = bindIn(op.InType, pathParams(op.Path), docFields(has, doc), hasBody(op.Method))
cmds = append(cmds, c)
cmds := ProjectCLI(a.Manifest())
// What a description cannot carry: the handler. A command spelled
// here runs in this process, so the op it projects is attached by the id
// both halves already address it by.
ops := make(map[string]*registeredOp, len(cmds))
for _, op := range a.Registry() {
ops[opName(op)] = op
}
for i := range cmds {
cmds[i].op = ops[cmds[i].OperationID]
}
sortCommands(cmds)
return cmds
}
+243
View File
@@ -0,0 +1,243 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// zipc projects an app, from a description of it rather than from a program.
//
// It reads a [zip.Manifest] — one JSON document, or a directory of the
// fragments a front end writes as it compiles — and writes the OpenAPI
// document, the MCP tool list, the CLI command tree and the ZAP IDL. Nothing it
// reads is Go and nothing it writes is Go, so the language that DECLARED the
// ops is the language it was declared in and no other:
//
// Go App.Manifest(), by reflection, at run time
// Rust #[zip::ops], by proc-macro, as the crate compiles
// C++ a libclang walk, as the build runs
//
// It is a build-time tool. A service built with it links none of it: the
// documents are files, the ZAP accessors come from zapgen, and what runs is the
// service's own language all the way down.
//
// Usage:
//
// zipc [-out DIR] [-package NAME] MANIFEST
//
// MANIFEST is a .json file, or a directory holding ops/*.json and types/*.json
// fragments.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/zap-proto/zip"
)
func main() {
out := flag.String("out", ".", "directory to write the projections into")
pkg := flag.String("package", "", "package name for the ZAP schema (default: the app's name)")
flag.Usage = usage
flag.Parse()
if flag.NArg() != 1 {
usage()
os.Exit(2)
}
if err := run(flag.Arg(0), *out, *pkg); err != nil {
fmt.Fprintln(os.Stderr, "zipc:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: zipc [-out DIR] [-package NAME] MANIFEST")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "MANIFEST is a manifest .json file, or a directory of ops/*.json")
fmt.Fprintln(os.Stderr, "and types/*.json fragments written by a front end.")
}
func run(in, out, pkg string) error {
m, err := read(in)
if err != nil {
return err
}
m.Settle()
if m.Manifest != zip.ManifestVersion {
return fmt.Errorf("%s: manifest version %d, this zipc reads %d", in, m.Manifest, zip.ManifestVersion)
}
if len(m.Ops) == 0 {
// An empty registry projects an empty document, which is worse than no
// document: it publishes that the service has nothing to offer.
return fmt.Errorf("%s: no ops; there is nothing to project", in)
}
if pkg == "" {
pkg = m.App
}
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
files := map[string][]byte{}
if files["openapi.json"], err = pretty(zip.ProjectOpenAPI(m)); err != nil {
return err
}
if files["mcp.json"], err = pretty(zip.ProjectMCP(m)); err != nil {
return err
}
if files["cli.json"], err = pretty(zip.ProjectCLI(m)); err != nil {
return err
}
files[pkg+".zap"] = []byte(zip.ProjectZAP(pkg, m).String())
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
at := filepath.Join(out, name)
if err := os.WriteFile(at, files[name], 0o644); err != nil {
return err
}
fmt.Println("zipc:", at)
}
return nil
}
// read is one manifest, from a file or from the fragments a compiler wrote.
func read(at string) (zip.Manifest, error) {
info, err := os.Stat(at)
if err != nil {
return zip.Manifest{}, err
}
if !info.IsDir() {
return one(at)
}
return assemble(at)
}
func one(at string) (zip.Manifest, error) {
b, err := os.ReadFile(at)
if err != nil {
return zip.Manifest{}, err
}
var m zip.Manifest
if err := json.Unmarshal(b, &m); err != nil {
return zip.Manifest{}, fmt.Errorf("%s: %w", at, err)
}
return m, nil
}
// assemble is one manifest out of the fragments a front end wrote as it
// compiled: one per impl block, one per described type.
//
// It is a merge and not a build. Nothing here decides anything about an op or a
// type — a fragment is already the finished description — so the order the
// files arrive in changes nothing, which is what lets a compiler write them as
// it reaches them.
func assemble(dir string) (zip.Manifest, error) {
m := zip.Manifest{Manifest: zip.ManifestVersion}
seen := map[string]bool{}
ops, err := filepath.Glob(filepath.Join(dir, "ops", "*.json"))
if err != nil {
return m, err
}
sort.Strings(ops)
for _, at := range ops {
var part zip.Manifest
if err := load(at, &part); err != nil {
return m, err
}
if part.App != "" {
m.App = part.App
}
if part.Title != "" {
m.Title = part.Title
}
if part.Version != "" {
m.Version = part.Version
}
if part.Description != "" {
m.Description = part.Description
}
m.Ops = append(m.Ops, part.Ops...)
}
types, err := filepath.Glob(filepath.Join(dir, "types", "*.json"))
if err != nil {
return m, err
}
sort.Strings(types)
for _, at := range types {
var td zip.TypeDesc
if err := load(at, &td); err != nil {
return m, err
}
if seen[td.ID] {
return m, fmt.Errorf("%s: two types described under %q; a name is how a field refers to a type, so it can only mean one", at, td.ID)
}
seen[td.ID] = true
m.Types = append(m.Types, td)
}
sort.Slice(m.Types, func(i, j int) bool { return m.Types[i].ID < m.Types[j].ID })
// A field naming a type nobody described is a dangling reference, and it is
// found HERE rather than in the document, where it would have become a
// schema of `{}` that every generated client reads as "any value".
for _, td := range m.Types {
for _, f := range td.Fields {
if err := resolves(seen, td.ID, f.Name, f.Type); err != nil {
return m, err
}
}
if td.Elem != nil {
if err := resolves(seen, td.ID, "element", *td.Elem); err != nil {
return m, err
}
}
}
for _, op := range m.Ops {
for what, id := range map[string]string{"input": op.In, "output": op.Out} {
if id != "" && !seen[id] {
return m, fmt.Errorf("op %s names %s %q, which nothing described", op.ID, what, id)
}
}
}
return m, nil
}
func resolves(seen map[string]bool, in, field string, r zip.TypeRef) error {
switch {
case r.Ref != "":
if !seen[r.Ref] {
return fmt.Errorf("%s.%s is %q, which nothing described", in, field, r.Ref)
}
case r.List != nil:
return resolves(seen, in, field, *r.List)
case r.Map != nil:
return resolves(seen, in, field, *r.Map)
}
return nil
}
func load(at string, into any) error {
b, err := os.ReadFile(at)
if err != nil {
return err
}
if err := json.Unmarshal(b, into); err != nil {
return fmt.Errorf("%s: %w", at, err)
}
return nil
}
func pretty(v any) ([]byte, error) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip_test
import (
"encoding/json"
"os"
"regexp"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// One service, written twice.
//
// corpus/info declares fourteen operations in Go; rust/examples/info declares
// the same fourteen in Rust, natively — a proc-macro reads the signature, the
// route and the doc comment, and writes the manifest as the crate compiles.
// Both manifests then go through the SAME projector, and what comes out must be
// the same bytes.
//
// That is the whole claim, and it is mechanical: if these pass, zip is one
// framework with two implementations of its front end. If they fail, the two
// have drifted, and the diff says exactly where.
//
// The Rust side's documents are checked in (rust/examples/info/gen), the way a
// generated file is checked in anywhere else, so this test needs no cargo and
// no zipc — and regenerating them is what makes a change to the Rust source
// show up here.
func rustSide(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("rust/examples/info/gen/" + name)
if err != nil {
t.Skipf("the Rust corpus has not been projected: %v", err)
}
return b
}
func goSide(t *testing.T, v any) []byte {
t.Helper()
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatal(err)
}
return append(b, '\n')
}
func TestGoAndRustProjectOneOpenAPI(t *testing.T) {
want := goSide(t, corpus().OpenAPISpec())
if got := rustSide(t, "openapi.json"); string(got) != string(want) {
t.Errorf("the two front ends publish different documents\n--- go\n%s\n--- rust\n%s", want, got)
}
}
func TestGoAndRustProjectOneToolList(t *testing.T) {
want := goSide(t, corpus().MCPTools())
if got := rustSide(t, "mcp.json"); string(got) != string(want) {
t.Errorf("the two front ends publish different tools\n--- go\n%s\n--- rust\n%s", want, got)
}
}
func TestGoAndRustProjectOneCommandTree(t *testing.T) {
want := goSide(t, corpus().Commands())
if got := rustSide(t, "cli.json"); string(got) != string(want) {
t.Errorf("the two front ends publish different commands\n--- go\n%s\n--- rust\n%s", want, got)
}
}
// The ZAP schemas agree about the CONTRACT — the structs, their field types,
// their offsets and the interface — and differ in two places that are facts
// about the language and not about the operation: a field's identifier follows
// the source's own case convention, and the ledger names a type the way the
// person who must go and change it would grep for it.
//
// This measures that rather than asserting it. Folding the identifier case
// leaves only the ledger's comment lines, and if anything else ever differs the
// count goes up and the test says which line.
func TestGoAndRustProjectOneZAPContract(t *testing.T) {
want := zip.ZAPSchema("info", corpus()).String()
got := string(rustSide(t, "info.zap"))
a, b := fold(want), fold(got)
if len(a) != len(b) {
t.Fatalf("the two schemas are %d and %d lines", len(a), len(b))
}
for i := range a {
if a[i] == b[i] {
continue
}
if isLedger(a[i]) && isLedger(b[i]) {
continue // a source-language spelling, addressed to a reader
}
t.Errorf("line %d of the schema differs\n go : %s\n rust: %s", i+1, a[i], b[i])
}
}
// fold reads a schema into lines with every field identifier's case and
// underscores removed, which is the one difference a language's own naming
// convention makes to a contract both compile to the same accessors from.
func fold(s string) []string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i, line := range lines {
m := fieldLine.FindStringSubmatch(line)
if m == nil {
continue
}
flat := strings.ToLower(strings.ReplaceAll(m[2], "_", ""))
lines[i] = m[1] + flat + " " + m[3] + m[4]
}
return lines
}
// fieldLine is one declared slot: an indent, a name, a type and an offset.
var fieldLine = regexp.MustCompile(`^(\s+)(\w+)\s+(\S+)(\s+@\d+)$`)
// isLedger reports a comment line, which is where the schema addresses a person
// rather than a compiler.
func isLedger(s string) bool { return strings.HasPrefix(strings.TrimSpace(s), "#") }
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package info
import (
"context"
"github.com/zap-proto/zip"
)
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Info answers questions about one node. It holds the answers rather than
// computing them, because this service exists to be projected, not to run a
// network.
type Info struct {
Release string
Network uint32
}
// Ops is this service's typed operations. Registering a handler yields the REST
// route, the OpenAPI document, the MCP tool, the CLI command and the ZAP method,
// all from this one registration and the doc comment above it.
func (i *Info) Ops() *zip.App {
app := zip.New(zip.Config{
AppName: "info",
DisableStartupMessage: true,
OpenAPI: zip.OpenAPIConfig{
Title: "Lux node info",
Description: "What a Lux node tells anyone who asks: what it is running, what network it is on, who it is connected to, and what its chains cost.",
Version: "1.0.0",
},
})
zip.Get(app, "/node/version", i.nodeVersion)
zip.Get(app, "/node/id", i.identity)
zip.Get(app, "/node/ip", i.address)
zip.Get(app, "/network/id", i.networkID)
zip.Get(app, "/network/name", i.networkName)
zip.Get(app, "/chain/id", i.chainID)
zip.Get(app, "/chain/bootstrapped", i.bootstrapped)
zip.Get(app, "/chains", i.chains)
zip.Get(app, "/peers", i.peers)
zip.Get(app, "/lps", i.lps)
zip.Get(app, "/vms", i.vms)
zip.Get(app, "/upgrades", i.upgrades)
zip.Get(app, "/uptime", i.uptime)
zip.Get(app, "/fees", i.fees)
return app
}
// NodeVersion is what this node is running: its own release, the database format
// it reads, and the VM protocol it speaks.
//
// Response: {"version": "luxd/1.36.178", "databaseVersion": "v1.4.5", "rpcProtocolVersion": 39, "gitCommit": ""}
func (i *Info) nodeVersion(context.Context, *struct{}) (*NodeVersion, error) {
return &NodeVersion{
Version: i.Release,
DatabaseVersion: "v1.4.5",
RPCProtocolVersion: 39,
}, nil
}
// Identity is this node's id, with the proof it holds the staking key that id is
// derived from.
//
// Response: {"nodeID": "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg", "nodePOP": {"publicKey": "0x8f95", "proofOfPossession": "0x86a3"}}
func (i *Info) identity(context.Context, *struct{}) (*NodeID, error) {
return &NodeID{
NodeID: "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg",
NodePOP: ProofOfPossession{PublicKey: "0x8f95", ProofOfPossession: "0x86a3"},
}, nil
}
// Address is where this node tells its peers to reach it.
//
// Response: {"ip": "203.0.113.9:9651"}
func (i *Info) address(context.Context, *struct{}) (*NodeIP, error) {
return &NodeIP{IP: "203.0.113.9:9651"}, nil
}
// NetworkID is the number of the network this node is on.
//
// Response: {"networkID": 96369}
func (i *Info) networkID(context.Context, *struct{}) (*NetworkID, error) {
return &NetworkID{NetworkID: i.Network}, nil
}
// NetworkName is the name of the network this node is on.
//
// Response: {"networkName": "mainnet"}
func (i *Info) networkName(context.Context, *struct{}) (*NetworkName, error) {
return &NetworkName{NetworkName: "mainnet"}, nil
}
// ChainID resolves a chain's alias to the id it names.
//
// Example: {"alias": "X"}
// Response: {"blockchainID": "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM"}
func (i *Info) chainID(_ context.Context, in *ChainAlias) (*ChainID, error) {
if in.Alias == "" {
return nil, zip.Errorf(400, "argument 'alias' not given")
}
return &ChainID{BlockchainID: "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM"}, nil
}
// Bootstrapped reports whether a chain has finished bootstrapping on this node.
//
// Example: {"chain": "X"}
// Response: {"isBootstrapped": true}
func (i *Info) bootstrapped(_ context.Context, in *BootstrappedArgs) (*Bootstrapped, error) {
if in.Chain == "" {
return nil, zip.Errorf(400, "argument 'chain' not given")
}
return &Bootstrapped{IsBootstrapped: true}, nil
}
// Chains are the chains this node is running, which is a subset of the chains
// the P-Chain knows about.
//
// Response: {"chains": [{"id": "11111111111111111111111111111111LpoYY", "name": "P-Chain", "vmID": "11111111111111111111111111111111LpoYY", "bootstrapped": true}]}
func (i *Info) chains(context.Context, *struct{}) (*Chains, error) {
return &Chains{Chains: []Chain{{
ID: "11111111111111111111111111111111LpoYY",
Name: "P-Chain",
VMID: "11111111111111111111111111111111LpoYY",
Bootstrapped: true,
}}}, nil
}
// Peers are the nodes this one is connected to. A list of node ids narrows the
// answer to those, and no list asks for all of them.
//
// 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": []}]}
func (i *Info) peers(_ context.Context, in *PeersArgs) (*Peers, error) {
peers := []Peer{{
IP: "203.0.113.9:9651",
PublicIP: "203.0.113.9:9651",
NodeID: "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg",
Version: "luxd/1.36.178",
ObservedUptime: 99,
TrackedChains: []string{},
}}
return &Peers{NumPeers: Uint64(len(peers)), Peers: peers}, nil
}
// LPs is where the network's stake stands on every current Lux Proposal.
//
// Response: {"lps": {"23": {"supportWeight": "0", "objectWeight": "0", "abstainWeight": "1000000000000"}}}
func (i *Info) lps(context.Context, *struct{}) (*LPs, error) {
return &LPs{LPs: map[string]LP{"23": {AbstainWeight: 1000000000000}}}, nil
}
// VMs are the virtual machines installed on this node, and the feature
// extensions its UTXO chains understand.
//
// Response: {"vms": {"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6": ["platformvm"]}, "fxs": {"spqBHsy2UBGpjaQJezEywUy1AB98eVjJ3WQ38x1vpjsx4xPkY": "secp256k1fx"}}
func (i *Info) vms(context.Context, *struct{}) (*VMs, error) {
return &VMs{
VMs: map[string][]string{"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6": {"platformvm"}},
Fxs: map[string]string{"spqBHsy2UBGpjaQJezEywUy1AB98eVjJ3WQ38x1vpjsx4xPkY": "secp256k1fx"},
}, nil
}
// Upgrades is the upgrade schedule this node runs.
//
// Response: {"stopVertexID": "jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga", "epochDuration": "300000000000"}
func (i *Info) upgrades(context.Context, *struct{}) (*Upgrades, error) {
return &Upgrades{
StopVertexID: "jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga",
EpochDuration: 300000000000,
}, nil
}
// Uptime is how much of the network, by stake, reports having seen this node up.
//
// Response: {"rewardingStakePercentage": "100.0000", "weightedAveragePercentage": "99.9999"}
func (i *Info) uptime(context.Context, *struct{}) (*Uptime, error) {
return &Uptime{
RewardingStakePercentage: "100.0000",
WeightedAveragePercentage: "99.9999",
}, nil
}
// Fees are the transaction fees this node charges, in nLUX.
//
// Response: {"txFee": "1000000", "createAssetTxFee": "10000000", "createChainTxFee": "1000000000"}
func (i *Info) fees(context.Context, *struct{}) (*Fees, error) {
return &Fees{TxFee: 1000000, CreateAssetTxFee: 10000000, CreateChainTxFee: 1000000000}, nil
}
+234
View File
@@ -0,0 +1,234 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// What a node tells anyone who asks — the conformance corpus, in Go.
//
// This service exists three times: here, in rust/examples/info, and in
// cpp/examples/info. The three are written in their own languages, in their own
// idioms, and each one's framework projects it without help from the other two.
// The projections they produce are then compared byte for byte, which is the
// only proof that zip is ONE framework with three implementations rather than
// one with two clients.
//
// The shapes are chosen to be the ones that break: a quoted decimal (a value
// whose JSON form is not what it is made of), a nested named struct, a list of
// structs, a map, a path-free GET with a query parameter, and an op that takes
// nothing at all.
package info
import "strconv"
// Uint64 is a 64-bit count carried as a quoted decimal, because JSON numbers
// lose precision above 2^53 and a stake does not.
//
// It is the case that makes the manifest carry two answers for one type: to a
// JSON reader this is a string, and to a fixed layout it is eight bytes. A
// description that could only say one of those would make one projection lie.
type Uint64 uint64
func (u Uint64) MarshalJSON() ([]byte, error) {
return strconv.AppendQuote(nil, strconv.FormatUint(uint64(u), 10)), nil
}
func (u *Uint64) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return err
}
*u = Uint64(n)
return nil
}
// MarshalText and UnmarshalText are the same value as ONE WORD, which is what a
// URL can carry. A quoted decimal is already text; saying so is what lets it be
// a query parameter as well as a body field.
func (u Uint64) MarshalText() ([]byte, error) {
return strconv.AppendUint(nil, uint64(u), 10), nil
}
func (u *Uint64) UnmarshalText(b []byte) error {
n, err := strconv.ParseUint(string(b), 10, 64)
if err != nil {
return err
}
*u = Uint64(n)
return nil
}
// JSONSchema states this type's wire form, next to the code that writes it.
func (Uint64) JSONSchema() map[string]any {
return map[string]any{"type": "string", "pattern": "^[0-9]+$"}
}
// NodeVersion is what this node is running.
type NodeVersion struct {
// Version is the node's own release.
Version string `json:"version"`
// DatabaseVersion is the database format this node reads.
DatabaseVersion string `json:"databaseVersion"`
// RPCProtocolVersion is the VM protocol this node speaks.
RPCProtocolVersion uint32 `json:"rpcProtocolVersion"`
// GitCommit is the commit this node was built from.
GitCommit string `json:"gitCommit"`
}
// NodeID is this node's id, with the proof it holds the staking key.
type NodeID struct {
// NodeID is the id peers know this node by.
NodeID string `json:"nodeID"`
// NodePOP is the proof this node holds the key its id is derived from.
NodePOP ProofOfPossession `json:"nodePOP"`
}
// ProofOfPossession is a staking key and a signature over it.
type ProofOfPossession struct {
// PublicKey is the BLS public key, hex encoded.
PublicKey string `json:"publicKey"`
// ProofOfPossession is the signature over that key, hex encoded.
ProofOfPossession string `json:"proofOfPossession"`
}
// NodeIP is where this node tells its peers to reach it.
type NodeIP struct {
// IP is the address and port peers dial.
IP string `json:"ip"`
}
// NetworkID is the number of the network a node is on.
type NetworkID struct {
// NetworkID is the network's number.
NetworkID uint32 `json:"networkID"`
}
// NetworkName is the name of the network a node is on.
type NetworkName struct {
// NetworkName is the network's name.
NetworkName string `json:"networkName"`
}
// ChainAlias names the chain a caller is asking about.
type ChainAlias struct {
// Alias is the name the chain answers to, such as "X".
Alias string `json:"alias" validate:"required"`
}
// ChainID is the id an alias names.
type ChainID struct {
// BlockchainID is the chain's id.
BlockchainID string `json:"blockchainID"`
}
// BootstrappedArgs names the chain whose bootstrap state is being asked about.
type BootstrappedArgs struct {
// Chain is the alias or id of the chain to ask about.
Chain string `json:"chain" validate:"required"`
}
// Bootstrapped is whether a chain has finished bootstrapping.
type Bootstrapped struct {
// IsBootstrapped is true once the chain has caught up.
IsBootstrapped bool `json:"isBootstrapped"`
}
// Chain is one chain this node runs.
type Chain struct {
// ID is the chain's id.
ID string `json:"id"`
// Name is the chain's name.
Name string `json:"name"`
// VMID is the id of the virtual machine the chain runs.
VMID string `json:"vmID"`
// Bootstrapped is whether this node has caught the chain up.
Bootstrapped bool `json:"bootstrapped"`
}
// Chains are the chains this node runs.
type Chains struct {
// Chains are the chains this node is running.
Chains []Chain `json:"chains"`
}
// PeersArgs narrows the peer list to the nodes named.
type PeersArgs struct {
// NodeIDs are the peers to answer for; none asks for all of them.
NodeIDs []string `json:"nodeIDs"`
}
// Peer is one connection this node holds.
type Peer struct {
// IP is the address this node reaches the peer at.
IP string `json:"ip"`
// PublicIP is the address the peer tells others to use.
PublicIP string `json:"publicIP"`
// NodeID is the peer's id.
NodeID string `json:"nodeID"`
// Version is the release the peer is running.
Version string `json:"version"`
// ObservedUptime is the share of time this node has seen the peer up.
ObservedUptime uint32 `json:"observedUptime"`
// TrackedChains are the chains the peer is following.
TrackedChains []string `json:"trackedChains"`
}
// Peers are the nodes this one is connected to.
type Peers struct {
// NumPeers is how many connections this node holds.
NumPeers Uint64 `json:"numPeers"`
// Peers are the connections themselves.
Peers []Peer `json:"peers"`
}
// LP is where the stake stands on one proposal.
type LP struct {
// SupportWeight is the stake in favour.
SupportWeight Uint64 `json:"supportWeight"`
// ObjectWeight is the stake against.
ObjectWeight Uint64 `json:"objectWeight"`
// AbstainWeight is the stake that has not said.
AbstainWeight Uint64 `json:"abstainWeight"`
}
// LPs is where the network's stake stands on every current proposal.
type LPs struct {
// LPs are the proposals, by number.
LPs map[string]LP `json:"lps"`
}
// VMs are the virtual machines installed on this node.
type VMs struct {
// VMs are the installed machines, by id, each with the names it answers to.
VMs map[string][]string `json:"vms"`
// Fxs are the feature extensions the UTXO chains understand, by id.
Fxs map[string]string `json:"fxs"`
}
// Upgrades is the upgrade schedule this node runs.
type Upgrades struct {
// StopVertexID is the vertex the linearisation stopped at.
StopVertexID string `json:"stopVertexID"`
// EpochDuration is how long one epoch lasts, in nanoseconds.
EpochDuration Uint64 `json:"epochDuration"`
}
// Uptime is how much of the network reports having seen this node up.
type Uptime struct {
// RewardingStakePercentage is the share of stake that would reward this node.
RewardingStakePercentage string `json:"rewardingStakePercentage"`
// WeightedAveragePercentage is the stake-weighted average uptime.
WeightedAveragePercentage string `json:"weightedAveragePercentage"`
}
// Fees are the transaction fees this node charges, in nLUX.
type Fees struct {
// TxFee is the fee for an ordinary transaction.
TxFee Uint64 `json:"txFee"`
// CreateAssetTxFee is the fee to create an asset.
CreateAssetTxFee Uint64 `json:"createAssetTxFee"`
// CreateChainTxFee is the fee to create a chain.
CreateChainTxFee Uint64 `json:"createChainTxFee"`
}
+141
View File
@@ -0,0 +1,141 @@
// Code generated by zipdoc; DO NOT EDIT.
package info
import (
"encoding/json"
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("github.com/zap-proto/zip/corpus/info GET /chain/bootstrapped", zip.Doc{
Description: "Bootstrapped reports whether a chain has finished bootstrapping on this node.",
Fields: map[string]string{
"Bootstrapped.isBootstrapped": "IsBootstrapped is true once the chain has caught up.",
"BootstrappedArgs.chain": "Chain is the alias or id of the chain to ask about.",
},
Example: json.RawMessage(`{"chain":"X"}`),
Response: json.RawMessage(`{"isBootstrapped":true}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /chain/id", zip.Doc{
Description: "ChainID resolves a chain's alias to the id it names.",
Fields: map[string]string{
"ChainAlias.alias": "Alias is the name the chain answers to, such as \"X\".",
"ChainID.blockchainID": "BlockchainID is the chain's id.",
},
Example: json.RawMessage(`{"alias":"X"}`),
Response: json.RawMessage(`{"blockchainID":"2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM"}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /chains", zip.Doc{
Description: "Chains are the chains this node is running, which is a subset of the chains\nthe P-Chain knows about.",
Fields: map[string]string{
"Chain.bootstrapped": "Bootstrapped is whether this node has caught the chain up.",
"Chain.id": "ID is the chain's id.",
"Chain.name": "Name is the chain's name.",
"Chain.vmID": "VMID is the id of the virtual machine the chain runs.",
"Chains.chains": "Chains are the chains this node is running.",
},
Response: json.RawMessage(`{"chains":[{"id":"11111111111111111111111111111111LpoYY","name":"P-Chain","vmID":"11111111111111111111111111111111LpoYY","bootstrapped":true}]}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /fees", zip.Doc{
Description: "Fees are the transaction fees this node charges, in nLUX.",
Fields: map[string]string{
"Fees.createAssetTxFee": "CreateAssetTxFee is the fee to create an asset.",
"Fees.createChainTxFee": "CreateChainTxFee is the fee to create a chain.",
"Fees.txFee": "TxFee is the fee for an ordinary transaction.",
},
Response: json.RawMessage(`{"txFee":"1000000","createAssetTxFee":"10000000","createChainTxFee":"1000000000"}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /lps", zip.Doc{
Description: "LPs is where the network's stake stands on every current Lux Proposal.",
Fields: map[string]string{
"LP.abstainWeight": "AbstainWeight is the stake that has not said.",
"LP.objectWeight": "ObjectWeight is the stake against.",
"LP.supportWeight": "SupportWeight is the stake in favour.",
"LPs.lps": "LPs are the proposals, by number.",
},
Response: json.RawMessage(`{"lps":{"23":{"supportWeight":"0","objectWeight":"0","abstainWeight":"1000000000000"}}}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /network/id", zip.Doc{
Description: "NetworkID is the number of the network this node is on.",
Fields: map[string]string{
"NetworkID.networkID": "NetworkID is the network's number.",
},
Response: json.RawMessage(`{"networkID":96369}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /network/name", zip.Doc{
Description: "NetworkName is the name of the network this node is on.",
Fields: map[string]string{
"NetworkName.networkName": "NetworkName is the network's name.",
},
Response: json.RawMessage(`{"networkName":"mainnet"}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /node/id", zip.Doc{
Description: "Identity is this node's id, with the proof it holds the staking key that id is\nderived from.",
Fields: map[string]string{
"NodeID.nodeID": "NodeID is the id peers know this node by.",
"NodeID.nodePOP": "NodePOP is the proof this node holds the key its id is derived from.",
"ProofOfPossession.proofOfPossession": "ProofOfPossession is the signature over that key, hex encoded.",
"ProofOfPossession.publicKey": "PublicKey is the BLS public key, hex encoded.",
},
Response: json.RawMessage(`{"nodeID":"NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg","nodePOP":{"publicKey":"0x8f95","proofOfPossession":"0x86a3"}}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /node/ip", zip.Doc{
Description: "Address is where this node tells its peers to reach it.",
Fields: map[string]string{
"NodeIP.ip": "IP is the address and port peers dial.",
},
Response: json.RawMessage(`{"ip":"203.0.113.9:9651"}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /node/version", zip.Doc{
Description: "NodeVersion is what this node is running: its own release, the database format\nit reads, and the VM protocol it speaks.",
Fields: map[string]string{
"NodeVersion.databaseVersion": "DatabaseVersion is the database format this node reads.",
"NodeVersion.gitCommit": "GitCommit is the commit this node was built from.",
"NodeVersion.rpcProtocolVersion": "RPCProtocolVersion is the VM protocol this node speaks.",
"NodeVersion.version": "Version is the node's own release.",
},
Response: json.RawMessage(`{"version":"luxd/1.36.178","databaseVersion":"v1.4.5","rpcProtocolVersion":39,"gitCommit":""}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /peers", zip.Doc{
Description: "Peers are the nodes this one is connected to. A list of node ids narrows the\nanswer to those, and no list asks for all of them.",
Fields: map[string]string{
"Peer.ip": "IP is the address this node reaches the peer at.",
"Peer.nodeID": "NodeID is the peer's id.",
"Peer.observedUptime": "ObservedUptime is the share of time this node has seen the peer up.",
"Peer.publicIP": "PublicIP is the address the peer tells others to use.",
"Peer.trackedChains": "TrackedChains are the chains the peer is following.",
"Peer.version": "Version is the release the peer is running.",
"Peers.numPeers": "NumPeers is how many connections this node holds.",
"Peers.peers": "Peers are the connections themselves.",
"PeersArgs.nodeIDs": "NodeIDs are the peers to answer for; none asks for all of them.",
},
Example: json.RawMessage(`{"nodeIDs":[]}`),
Response: json.RawMessage(`{"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":[]}]}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /upgrades", zip.Doc{
Description: "Upgrades is the upgrade schedule this node runs.",
Fields: map[string]string{
"Upgrades.epochDuration": "EpochDuration is how long one epoch lasts, in nanoseconds.",
"Upgrades.stopVertexID": "StopVertexID is the vertex the linearisation stopped at.",
},
Response: json.RawMessage(`{"stopVertexID":"jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga","epochDuration":"300000000000"}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /uptime", zip.Doc{
Description: "Uptime is how much of the network, by stake, reports having seen this node up.",
Fields: map[string]string{
"Uptime.rewardingStakePercentage": "RewardingStakePercentage is the share of stake that would reward this node.",
"Uptime.weightedAveragePercentage": "WeightedAveragePercentage is the stake-weighted average uptime.",
},
Response: json.RawMessage(`{"rewardingStakePercentage":"100.0000","weightedAveragePercentage":"99.9999"}`),
})
zip.Describe("github.com/zap-proto/zip/corpus/info GET /vms", zip.Doc{
Description: "VMs are the virtual machines installed on this node, and the feature\nextensions its UTXO chains understand.",
Fields: map[string]string{
"VMs.fxs": "Fxs are the feature extensions the UTXO chains understand, by id.",
"VMs.vms": "VMs are the installed machines, by id, each with the names it answers to.",
},
Response: json.RawMessage(`{"vms":{"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6":["platformvm"]},"fxs":{"spqBHsy2UBGpjaQJezEywUy1AB98eVjJ3WQ38x1vpjsx4xPkY":"secp256k1fx"}}`),
})
}
+513
View File
@@ -0,0 +1,513 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
)
// The registry, said without Go in it.
//
// Every projection in this package — the OpenAPI document, the MCP tool list,
// the CLI, the GraphQL schema, the ZAP IDL, the SDKs — reads a *registeredOp,
// and a registeredOp holds two reflect.Types and three closures. That is why
// they are Go's alone: a Rust service has no reflect.Type to hand them, so the
// only way it ever reached them was to have a Go program declare its ops for
// it, which is not a Rust service declaring anything.
//
// A Manifest is the same registry with the Go taken out: an op is a method, a
// path, an id, its prose and the NAMES of its input and output; a type is a
// name, a kind, and a list of fields with their wire names, their prose and
// their slots. Everything the projections read is here and nothing here is Go,
// so the projections can read this instead — and then a front end that can fill
// one in gets all of them, whatever language it is written in.
//
// There are three such front ends. Go fills a Manifest in by reflection, at run
// time, from the types it already holds ([App.Manifest]). Rust fills it in at
// COMPILE time, from a proc-macro that reads the handler's signature, its path
// attribute and its doc comments in one pass. C++ fills it in at BUILD time,
// from a libclang walk of the same declarations. None of them is privileged and
// none of them needs another.
//
// The manifest is a description, never a program: it carries no closure, no
// address and no way to run anything. What runs an op stays in the language the
// op was written in.
// ManifestVersion is the shape of the document. It is a number in the document
// itself so a projector reading one written by an older front end says so
// rather than mis-reading a field that moved.
const ManifestVersion = 1
// Manifest is one app, described so that anything can project it.
type Manifest struct {
Manifest int `json:"manifest"`
// App is the name the app declared for itself. It names the ZAP interface
// and titles the document when nothing else does.
App string `json:"app"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Version string `json:"version,omitempty"`
// Ops are this app's typed operations, in registration order — the same
// order [App.Registry] returns, so a projection that sorts sorts once.
Ops []ManifestOp `json:"ops"`
// Types are every type the ops reach, each described once and referred to
// by id. A type reached by two ops is one entry both point at, which is
// what makes one type one schema across every projection.
Types []TypeDesc `json:"types"`
}
// ManifestOp is one operation.
type ManifestOp struct {
Method string `json:"method"`
Path string `json:"path"`
// ID is the op's identity — the OpenAPI operationId, the MCP tool name, the
// CLI's spelling and the key the call plane resolves. It is filled in by the
// front end from the SAME rule [ID] states, so a manifest that names an op
// names it the way every other surface does.
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
// Description is the handler's whole doc comment, minus the Example and
// Response lines. The summary falls back to its first sentence.
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Statuses []int `json:"statuses,omitempty"`
ResponseHeaders []string `json:"responseHeaders,omitempty"`
// Pkg is the module the handler was declared in and Origin the app that
// declared it when it arrived by composition. Together they say WHO owns
// this op, which is what keeps two services' /height apart.
Pkg string `json:"pkg,omitempty"`
Origin string `json:"origin,omitempty"`
// In and Out are TypeDesc ids. Empty means the op takes or answers nothing.
In string `json:"in,omitempty"`
Out string `json:"out,omitempty"`
// Example and Response are the request and answer the doc comment wrote,
// raw so they land in the document exactly as written.
Example json.RawMessage `json:"example,omitempty"`
Response json.RawMessage `json:"response,omitempty"`
// Gated says an Authorizer is in force, so the document publishes the held
// answer beside the finished one.
Gated bool `json:"gated,omitempty"`
}
// TypeDesc is one type, described by what it is made of and by what it looks
// like on the wire — which are not always the same thing, and that is the
// reason both are here. A quoted decimal is a u64 to a fixed layout and a
// string to a JSON reader; a description that could only say one of those would
// make one of the two projections wrong.
type TypeDesc struct {
// ID is how fields refer to this type within one manifest.
ID string `json:"id"`
// Name is the type's declared name, empty for an anonymous one.
Name string `json:"name,omitempty"`
// Pkg is the module or crate the type was declared in. It is what tells two
// Config types apart when both reach one document.
Pkg string `json:"pkg,omitempty"`
// Kind is what a reader must do with it: "struct" has fields, "list" and
// "map" hold something, "scalar" is a value. It is STRUCTURAL and says
// nothing about the wire form — a type that states its own form says so in
// JSON, and the two are separate because they disagree on purpose: a quoted
// decimal is a string to a reader and eight bytes to a layout.
Kind string `json:"kind"`
// Spell is this type as the source language writes it. See [FieldDesc.Spell].
Spell string `json:"spell,omitempty"`
// JSON is the schema this type states for ITSELF — the answer a type gives
// when its fields are not its wire form. Present for a quoted number, a
// timestamp, a raw message. A projection that has one uses it and does not
// look inside.
JSON json.RawMessage `json:"json,omitempty"`
// Text says this type reads and writes itself as one word, so a URL can
// carry it whatever it is made of.
Text bool `json:"text,omitempty"`
// Repr is the fixed-layout spelling of a scalar: bool, i8..i64, u8..u64,
// f32, f64, text, bytes, bytes_fixed[N].
Repr string `json:"repr,omitempty"`
// Elem is what a named list or map holds. A named slice is still a list of
// something, and a description that forgot what would publish an array of
// untyped objects.
Elem *TypeRef `json:"elem,omitempty"`
// Len is a list's fixed length, 0 for one that has none. A fixed-length
// list of anything but bytes has no place in a layout — an element is one
// value at one width — so the length is what tells the two apart.
Len int `json:"len,omitempty"`
Fields []FieldDesc `json:"fields,omitempty"`
}
// FieldDesc is one field of a struct.
type FieldDesc struct {
// Name is the field as the source declares it; JSON is what the wire calls
// it, "-" for a field the body does not carry.
Name string `json:"name"`
JSON string `json:"json"`
// URL is the name a URL carries it under and Header the request header it
// reads. Both empty means it rides the body alone.
URL string `json:"url,omitempty"`
Header string `json:"header,omitempty"`
Required bool `json:"required,omitempty"`
Doc string `json:"doc,omitempty"`
Type TypeRef `json:"type"`
// Spell is this field's type as the SOURCE LANGUAGE writes it. It is the
// one string in a manifest that is deliberately not neutral, and it is read
// by exactly one reader: the ledger the ZAP projection writes for the person
// who has to go and change that declaration. Sending them to
// "map[string]info.LP" when the file says HashMap<String, Lp> is worse than
// saying nothing.
Spell string `json:"spell,omitempty"`
}
// TypeRef names a field's type: a primitive by its spelling, a described type
// by its id, or a container around either.
type TypeRef struct {
Prim string `json:"prim,omitempty"`
Ref string `json:"ref,omitempty"`
List *TypeRef `json:"list,omitempty"`
// Len is a fixed-length list's length. See [TypeDesc.Len].
Len int `json:"len,omitempty"`
// Map is a map's VALUE type; a key is a string on every wire that has maps.
Map *TypeRef `json:"map,omitempty"`
// Opt says the value may be absent — a pointer, an Option, a nullable.
Opt bool `json:"opt,omitempty"`
}
// Settle fills in what a front end may leave to the projector.
//
// An op's ID is the one thing a front end must NOT decide for itself. It is
// [ID]'s rule, and a rule stated in three languages is a rule that will be
// spelled three ways — so a manifest may arrive without one and this settles it,
// which is how a Rust op and a Go op at the same address come out carrying the
// same token. A front end that has an explicit id (an operation renamed on
// purpose) keeps it.
func (m *Manifest) Settle() {
if m.Manifest == 0 {
m.Manifest = ManifestVersion
}
for i := range m.Ops {
if m.Ops[i].ID == "" {
m.Ops[i].ID = ID(m.Ops[i].Method, m.Ops[i].Path)
}
}
}
// Manifest describes this app the way any front end would, so the projections
// read the same document whichever language declared the ops.
func (a *App) Manifest() Manifest {
m := Manifest{
Manifest: ManifestVersion,
App: a.cfg.AppName,
Title: a.cfg.OpenAPI.Title,
Description: a.cfg.OpenAPI.Description,
Version: a.cfg.OpenAPI.Version,
}
d := &describer{
byType: map[reflect.Type]string{},
taken: map[string]bool{},
}
for _, op := range a.Registry() {
doc, hasDoc := docFor(op.Pkg, op.Method, op.Path)
mo := ManifestOp{
Method: op.Method,
Path: op.Path,
ID: opName(op),
Summary: op.Summary,
Tags: op.Tags,
Statuses: op.Statuses,
ResponseHeaders: op.ResponseHeaders,
Pkg: op.Pkg,
Origin: op.Origin,
In: d.describe(op.InType, op.Origin),
Out: d.describe(op.OutType, op.Origin),
Gated: op.rule != nil && op.rule() != nil,
}
if hasDoc {
mo.Description = doc.Description
mo.Example = doc.Example
mo.Response = doc.Response
d.prose(doc.Fields)
}
m.Ops = append(m.Ops, mo)
}
m.Types = d.types()
return m
}
// describer walks the types an op reaches and files each one once.
type describer struct {
byType map[reflect.Type]string
taken map[string]bool
out []*TypeDesc
anon int
}
// describe files t and returns its id, or "" for a type that carries nothing —
// which is what an op with no input or no answer has.
func (d *describer) describe(t reflect.Type, origin string) string {
if t == nil {
return ""
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() == reflect.Struct && t.NumField() == 0 && t.Name() == "" {
return "" // struct{}: the op takes nothing.
}
if id, ok := d.byType[t]; ok {
return id
}
id := d.id(t, origin)
d.byType[t] = id
td := &TypeDesc{ID: id, Name: t.Name(), Pkg: t.PkgPath(), Spell: goName(t), Text: readsText(t)}
d.out = append(d.out, td) // claimed before the fields are walked: the cycle guard.
if isMarshaler(t) {
switch {
case declaredSchema(t) != nil:
td.JSON = rawJSON(declaredSchema(t))
case t == timeType:
td.JSON = rawJSON(map[string]any{"type": "string", "format": "date-time"})
default:
td.JSON = json.RawMessage("{}")
}
}
switch t.Kind() {
case reflect.Struct:
td.Kind = "struct"
d.fields(td, t, origin)
case reflect.Slice, reflect.Array:
td.Repr = reprOf(t)
if t.Elem().Kind() == reflect.Uint8 {
td.Kind = "scalar"
d.state(td, t)
break
}
td.Kind = "list"
if t.Kind() == reflect.Array {
td.Len = t.Len()
}
e := d.ref(t.Elem(), origin)
td.Elem = &e
case reflect.Map:
td.Kind, td.Repr = "map", reprOf(t)
e := d.ref(t.Elem(), origin)
td.Elem = &e
default:
td.Kind, td.Repr = "scalar", reprOf(t)
d.state(td, t)
}
return id
}
// state gives a value type the shape it shows on the wire, since it has no
// fields to be described by. A type that already stated its own keeps it.
func (d *describer) state(td *TypeDesc, t reflect.Type) {
if len(td.JSON) == 0 {
td.JSON = rawJSON(schemaOf(t, nil, nil))
}
}
// fields describes the fields the wire carries: the struct's own and the
// promoted ones of anything it embeds, in the order a decoder resolves them.
func (d *describer) fields(td *TypeDesc, t reflect.Type, origin string) {
for _, f := range wireFields(t) {
fd := FieldDesc{
Name: f.Name,
JSON: jsonFieldName(f),
URL: urlFieldName(f),
Header: headerFieldName(f),
Required: strings.Contains(f.Tag.Get("validate"), "required"),
Type: d.ref(f.Type, origin),
Spell: goName(f.Type),
}
if fd.URL == fd.JSON {
fd.URL = "" // says nothing: the wire name is the URL name.
}
td.Fields = append(td.Fields, fd)
}
}
// ref names a field's type without describing it twice: a primitive by its
// spelling, anything with a shape by the id it is filed under.
func (d *describer) ref(t reflect.Type, origin string) TypeRef {
r := TypeRef{}
if t.Kind() == reflect.Pointer {
r.Opt = true
t = t.Elem()
}
// A named type with a wire form of its own is described, not spelled: a
// quoted decimal is not "u64" to a JSON reader and not "string" to a layout.
if t.Name() != "" && (isMarshaler(t) || readsText(t) || t.Kind() == reflect.Struct) {
r.Ref = d.describe(t, origin)
return r
}
switch t.Kind() {
case reflect.Struct:
r.Ref = d.describe(t, origin)
case reflect.Slice, reflect.Array:
if t.Elem().Kind() == reflect.Uint8 {
r.Prim = "bytes"
if t.Kind() == reflect.Array {
r.Prim = "bytes_fixed[" + strconv.Itoa(t.Len()) + "]"
}
return r
}
e := d.ref(t.Elem(), origin)
r.List = &e
if t.Kind() == reflect.Array {
r.Len = t.Len()
}
case reflect.Map:
e := d.ref(t.Elem(), origin)
r.Map = &e
default:
r.Prim = primOf(t.Kind())
}
return r
}
// prose files the doc comments the extraction found onto the fields they
// describe. The key is "<Type>.<wire name>", which is what cmd/zipdoc wrote and
// what a Rust or C++ front end fills in directly from the comment above the
// field — so one key reaches a field's prose whichever language declared it.
func (d *describer) prose(fields map[string]string) {
for _, td := range d.out {
for i := range td.Fields {
if td.Fields[i].Doc != "" {
continue
}
if s := fields[td.Name+"."+td.Fields[i].JSON]; s != "" {
td.Fields[i].Doc = s
}
}
}
}
// types is the described set, in id order so one app yields one document
// whatever order its ops were registered in.
func (d *describer) types() []TypeDesc {
out := make([]TypeDesc, 0, len(d.out))
for _, td := range d.out {
out = append(out, *td)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// id is the key a type is filed under: its name, qualified by the app that
// declared it when it came in by composition, then by its package when a
// different type already holds that name. It is [schemaRegistry.nameFor]'s rule,
// stated once more where there is no reflect.Type to key a map by.
func (d *describer) id(t reflect.Type, origin string) string {
qual := ""
if origin != "" {
qual = origin + "."
}
if t.Name() == "" {
d.anon++
return fmt.Sprintf("#%d", d.anon)
}
base := qual + t.Name()
if !d.taken[base] {
d.taken[base] = true
return base
}
if p := t.PkgPath(); p != "" {
base = qual + p[strings.LastIndexByte(p, '/')+1:] + "." + t.Name()
}
for name, n := base, 2; ; n++ {
if !d.taken[name] {
d.taken[name] = true
return name
}
name = base + strconv.Itoa(n)
}
}
// primOf is a scalar's neutral spelling — the one a fixed layout and a schema
// both read, since the width is what a generated accessor needs and "integer"
// alone does not carry it.
func primOf(k reflect.Kind) string {
switch k {
case reflect.Bool:
return "bool"
case reflect.String:
return "string"
case reflect.Int8:
return "i8"
case reflect.Int16:
return "i16"
case reflect.Int32:
return "i32"
case reflect.Int, reflect.Int64:
return "i64"
case reflect.Uint8:
return "u8"
case reflect.Uint16:
return "u16"
case reflect.Uint32:
return "u32"
case reflect.Uint, reflect.Uint64:
return "u64"
case reflect.Float32:
return "f32"
case reflect.Float64:
return "f64"
}
return "any"
}
// reprOf is a named scalar's fixed-layout spelling.
func reprOf(t reflect.Type) string {
switch t.Kind() {
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "bytes"
}
return "list"
case reflect.Array:
if t.Elem().Kind() == reflect.Uint8 {
return "bytes_fixed[" + strconv.Itoa(t.Len()) + "]"
}
return "list"
case reflect.Map:
return "bytes"
case reflect.String:
return "text"
}
return primOf(t.Kind())
}
func rawJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage("{}")
}
return b
}
+4
View File
@@ -631,6 +631,10 @@ func (a *App) MCPTools() []map[string]any { return a.mcpTools() }
// build-time catalogue — and an artifact ordered by registration churns on an
// edit that changed nothing a client can see.
func (a *App) mcpTools() []map[string]any {
return ProjectMCP(a.Manifest())
}
func (a *App) mcpToolsReflect() []map[string]any {
reg := a.Registry()
tools := make([]map[string]any, 0, len(reg))
for _, op := range reg {
+10
View File
@@ -60,6 +60,10 @@ func (a *App) installOpenAPIRoutes() {
// buildOpenAPI walks the registered typed ops and builds an OpenAPI 3.1
// spec as a plain map (json.Marshal serializes anything map-shaped).
func (a *App) buildOpenAPI() map[string]any {
return ProjectOpenAPI(a.Manifest())
}
func (a *App) buildOpenAPIReflect() map[string]any {
cfg := a.cfg.OpenAPI
if cfg.Title == "" {
cfg.Title = a.cfg.AppName
@@ -788,6 +792,11 @@ type schemaRegistry struct {
defs map[string]any // name → definition
names map[reflect.Type]string // type → the name it is defined under
refs map[string]int // name → how many $refs point at it
// byID is the same map for a type that arrived as a description rather than
// as a reflect.Type — a manifest's type id → the name it is defined under.
// Two keys, one registry, because a document is assembled the same way
// whichever front end described the ops.
byID map[string]string
// origin is the app that DECLARED the op currently being described, when
// that op arrived through composition — empty for the host's own. It
@@ -805,6 +814,7 @@ func newSchemaRegistry(prefix string) *schemaRegistry {
defs: map[string]any{},
names: map[reflect.Type]string{},
refs: map[string]int{},
byID: map[string]string{},
}
}
+636
View File
@@ -0,0 +1,636 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip
import (
"encoding/json"
"maps"
"sort"
"strconv"
"strings"
)
// The projections, read off a [Manifest] instead of off Go.
//
// These are the same documents [App.OpenAPISpec], [App.MCPTools] and
// [App.Commands] produce, derived from a description rather than from a
// reflect.Type — which is what lets a Rust or a C++ service have them. The rules
// they apply are the package's own and are not restated here: the operation id
// is [ID], the summary is [firstSentence], the path template is [Template], the
// parameters are [pathParams], the command tree is [CommandsFromSpec]. A rule
// stated twice is a rule that will be spelled two ways, and this package has the
// scar to prove it.
//
// The manifest-sourced document and the reflect-sourced one are pinned
// byte-identical over the corpus (TestProjectMatchesRegistry). That equality is
// the whole safety of the design: it says the manifest lost nothing, so a front
// end that can fill one in has everything Go has.
// ProjectOpenAPI is m as an OpenAPI 3.1 document.
func ProjectOpenAPI(m Manifest) map[string]any {
p := newProjector(m)
title := m.Title
if title == "" {
title = m.App
}
if title == "" {
title = "zip API"
}
version := m.Version
if version == "" {
version = "0.0.0"
}
ops := append([]ManifestOp(nil), m.Ops...)
sort.Slice(ops, func(i, j int) bool {
if ops[i].Path != ops[j].Path {
return ops[i].Path < ops[j].Path
}
return ops[i].Method < ops[j].Method
})
reg := newSchemaRegistry(specDefs)
paths := map[string]map[string]any{}
for _, op := range ops {
path := Template(op.Path)
if _, ok := paths[path]; !ok {
paths[path] = map[string]any{}
}
obj := map[string]any{"operationId": op.ID, "summary": op.Summary}
if op.Description != "" {
obj["description"] = op.Description
if op.Summary == "" {
obj["summary"] = firstSentence(op.Description)
}
}
if len(op.Tags) > 0 {
obj["tags"] = op.Tags
}
if p.hasRequestBody(op) {
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.In}, reg)}
if len(op.Example) > 0 {
media["example"] = json.RawMessage(op.Example)
}
obj["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{"application/json": media},
}
}
url := p.urlFields(op.In)
example := exampleFields(op.Example)
describe := func(decl map[string]any, docField, wire string) map[string]any {
if help := p.doc(op.In, docField); help != "" {
decl["description"] = help
}
if v, ok := example[wire]; ok {
decl["example"] = v
}
return decl
}
params := pathParams(op.Path)
decls := make([]any, 0, len(params))
named := make(map[string]bool, len(params))
for _, q := range params {
named[strings.ToLower(q.Name)] = true
named[strings.ToLower(q.Key)] = true
decls = append(decls, describe(map[string]any{
"name": q.Name, "in": "path", "required": true,
"schema": url.paramSchema(q.Key),
}, url.docKey(q.Key), q.Key))
}
for _, h := range p.headerFields(op.In) {
named[strings.ToLower(h.field)] = true
decls = append(decls, describe(map[string]any{
"name": h.header, "in": "header", "required": h.required,
"schema": h.schema,
}, h.field, h.field))
}
if !hasBody(op.Method) {
for _, f := range url {
if named[strings.ToLower(f.name)] {
continue
}
decls = append(decls, describe(map[string]any{
"name": f.name, "in": "query", "required": f.required,
"schema": f.schema,
}, url.docKey(f.name), f.name))
}
}
if len(decls) > 0 {
obj["parameters"] = decls
}
resp := map[string]any{}
if out := p.types[op.Out]; out != nil && out.Name != "" {
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.Out}, reg)}
if len(op.Response) > 0 {
media["example"] = json.RawMessage(op.Response)
}
for _, code := range statuses(op.Statuses, 200) {
entry := map[string]any{
"description": statusText(code),
"content": map[string]any{"application/json": media},
}
if h := headerDecls(op); h != nil {
entry["headers"] = h
}
resp[strconv.Itoa(code)] = entry
}
} else {
for _, code := range statuses(op.Statuses, 204) {
entry := map[string]any{"description": statusText(code)}
if h := headerDecls(op); h != nil {
entry["headers"] = h
}
resp[strconv.Itoa(code)] = entry
}
}
if op.Gated {
if _, taken := resp["202"]; !taken {
resp["202"] = map[string]any{
"description": "held for approval",
"content": map[string]any{
"application/json": map[string]any{"schema": schemaOf(approvalType, reg, nil)},
},
}
}
}
obj["responses"] = resp
paths[path][strings.ToLower(op.Method)] = obj
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": title,
"description": m.Description,
"version": version,
},
"paths": paths,
"components": map[string]any{"schemas": reg.defs},
}
}
// ProjectMCP is m as an MCP tool list, in name order.
func ProjectMCP(m Manifest) []map[string]any {
p := newProjector(m)
tools := make([]map[string]any, 0, len(m.Ops))
for _, op := range m.Ops {
desc := op.Summary
if op.Description != "" {
desc = op.Description
}
annotations := map[string]any{}
if op.Method == "GET" || op.Method == "HEAD" {
annotations["readOnlyHint"] = true
}
tools = append(tools, map[string]any{
"name": op.ID,
"description": desc,
"inputSchema": p.rootSchema(op.In),
"annotations": annotations,
})
}
sort.Slice(tools, func(i, j int) bool {
return tools[i]["name"].(string) < tools[j]["name"].(string)
})
return tools
}
// ProjectCLI is m as a command tree.
//
// It reads the manifest rather than the document it also produces, and that is
// a deliberate difference from [CommandsFromSpec]: a document says which fields
// the BODY carries and which the URL does, and it cannot say that a body field
// opts OUT of the URL. A worker's source and its name are both spelled "script"
// — one in the path, one in the body — and a derivation that only sees the
// document drops the second as already-addressed, leaving a command that cannot
// send the body at all. The manifest carries both halves, so this one does not
// have to guess.
func ProjectCLI(m Manifest) []Command {
p := newProjector(m)
cmds := make([]Command, 0, len(m.Ops))
for _, op := range m.Ops {
c := Command{
Service: "", Name: "",
OperationID: op.ID,
Summary: op.Summary,
Description: op.Description,
Method: op.Method,
Path: op.Path,
Example: op.Example,
}
c.Service, c.Name = commandName(op.Method, op.Path, op.ID)
if c.Summary == "" {
c.Summary = firstSentence(op.Description)
}
c.Args, c.Flags = p.bind(op)
cmds = append(cmds, c)
}
sortCommands(cmds)
return cmds
}
// bind splits an op's input into positional args and flags: the URL addresses
// the resource, so what addresses it is positional and what modifies the
// request is a flag.
func (p *projector) bind(op ManifestOp) ([]Arg, []Flag) {
params := pathParams(op.Path)
td := p.types[op.In]
args := make([]Arg, 0, len(params))
for _, q := range params {
args = append(args, Arg{Name: q.Name, Help: p.doc(op.In, q.Name)})
}
if td == nil || td.Kind != "struct" {
return args, nil
}
var flags []Flag
add := func(name, url, kind string, required bool) {
if name == "-" || isParam(params, url) {
return
}
flags = append(flags, Flag{
Name: kebab(name),
Field: name,
Type: kind,
Help: p.doc(op.In, name),
Required: required,
})
}
if hasBody(op.Method) {
for _, f := range td.Fields {
add(f.JSON, urlName(f), p.flagType(f.Type), f.Required)
}
} else {
for _, f := range p.urlFields(op.In) {
kind, _ := f.schema["type"].(string)
add(f.name, f.name, specType(kind), f.required)
}
}
return args, flags
}
// flagType is a flag's value kind, read off the ONE schema derivation — the
// same rule [flagType] applies to a Go type, applied to a described one.
func (p *projector) flagType(r TypeRef) string {
kind, _ := p.schema(r, nil)["type"].(string)
return specType(kind)
}
// projector holds the type table while one document is assembled.
type projector struct {
types map[string]*TypeDesc
}
func newProjector(m Manifest) *projector {
p := &projector{types: make(map[string]*TypeDesc, len(m.Types))}
for i := range m.Types {
p.types[m.Types[i].ID] = &m.Types[i]
}
return p
}
// doc is the prose written for one field of a type.
func (p *projector) doc(id, field string) string {
td := p.types[id]
if td == nil {
return ""
}
for _, f := range td.Fields {
if f.JSON == field {
return f.Doc
}
}
return ""
}
// schema is [schemaOf] over a described type: a named struct is defined in reg
// and referred to by $ref, everything else is inlined.
func (p *projector) schema(r TypeRef, reg *schemaRegistry) map[string]any {
switch {
case r.Ref != "":
td := p.types[r.Ref]
if td == nil {
return map[string]any{"type": "object"}
}
if len(td.JSON) > 0 {
return decode(td.JSON)
}
switch td.Kind {
case "list":
return map[string]any{"type": "array", "items": p.schema(*td.Elem, reg)}
case "map":
return map[string]any{"type": "object", "additionalProperties": p.schema(*td.Elem, reg)}
case "struct":
if reg == nil {
return map[string]any{"type": "object"}
}
if td.Name == "" {
out := map[string]any{}
p.fill(out, td, reg)
return out
}
return reg.ref(p.define(td, reg))
}
return map[string]any{"type": "object"}
case r.List != nil:
return map[string]any{"type": "array", "items": p.schema(*r.List, reg)}
case r.Map != nil:
return map[string]any{"type": "object", "additionalProperties": p.schema(*r.Map, reg)}
}
return primSchema(r.Prim)
}
// define claims td's entry before filling it, which is the cycle guard.
func (p *projector) define(td *TypeDesc, reg *schemaRegistry) string {
if name, ok := reg.byID[td.ID]; ok {
return name
}
name := td.ID
def := map[string]any{}
reg.byID[td.ID] = name
reg.defs[name] = def
p.fill(def, td, reg)
return name
}
// fill is [structSchema] over a described struct.
func (p *projector) fill(into map[string]any, td *TypeDesc, reg *schemaRegistry) {
props := map[string]any{}
var required []string
for _, f := range td.Fields {
if f.JSON == "-" {
continue
}
fs := p.schema(f.Type, reg)
if f.Doc != "" {
fs["description"] = f.Doc
}
props[f.JSON] = fs
if f.Required {
required = append(required, f.JSON)
}
}
into["type"] = "object"
into["properties"] = props
if len(required) > 0 {
into["required"] = required
}
}
// rootSchema is [rootSchemaOf] over a described type: the type inlined, with
// everything else it reaches carried alongside in $defs.
func (p *projector) rootSchema(id string) map[string]any {
if id == "" {
// An op that takes nothing has an EMPTY object for its arguments, not an
// unconstrained one: a tool whose schema says "any object" invites an
// agent to invent arguments the handler will never read.
return map[string]any{"type": "object", "properties": map[string]any{}}
}
reg := newSchemaRegistry(selfDefs)
root := p.schema(TypeRef{Ref: id}, reg)
ref, isRef := root["$ref"].(string)
if !isRef {
return root
}
name := strings.TrimPrefix(ref, reg.prefix)
root = maps.Clone(reg.defs[name].(map[string]any))
if reg.refs[name] == 1 {
delete(reg.defs, name)
}
if len(reg.defs) > 0 {
root["$defs"] = reg.defs
}
return root
}
// hasRequestBody is [hasRequestBody] over a described op.
func (p *projector) hasRequestBody(op ManifestOp) bool {
td := p.types[op.In]
if !hasBody(op.Method) || td == nil || td.Name == "" {
return false
}
if td.Kind != "struct" {
return true // the body IS the whole value.
}
named := map[string]bool{}
for _, q := range pathParams(op.Path) {
named[strings.ToLower(q.Name)] = true
named[strings.ToLower(q.Key)] = true
}
for _, f := range td.Fields {
if f.JSON == "-" {
continue
}
if !named[strings.ToLower(urlName(f))] {
return true
}
}
return false
}
// urlFields is [urlFields] over a described input.
func (p *projector) urlFields(id string) urlFieldList {
var out urlFieldList
p.collectURL(id, "", map[string]bool{}, &out)
return out
}
func (p *projector) collectURL(id, prefix string, inside map[string]bool, out *urlFieldList) {
td := p.types[id]
if td == nil || td.Kind != "struct" || inside[id] {
return
}
inside[id] = true
defer delete(inside, id)
for _, f := range td.Fields {
name := urlName(f)
if name == "-" {
continue
}
name = prefix + name
if inner := p.record(f.Type); inner != "" {
p.collectURL(inner, name+".", inside, out)
continue
}
schema := p.urlSchema(f.Type)
if schema == nil {
continue
}
*out = append(*out, urlField{name: name, field: f.JSON, schema: schema, required: f.Required})
}
}
// record is the id of the struct a URL names the leaves of THROUGH this field,
// or "" when the URL carries the value itself.
func (p *projector) record(r TypeRef) string {
if r.Ref == "" {
return ""
}
td := p.types[r.Ref]
if td == nil || td.Kind != "struct" || td.Text || len(td.JSON) > 0 {
return ""
}
return td.ID
}
// urlSchema is [urlSchema] over a described type: what a URL can carry into it,
// and nil for what it cannot.
func (p *projector) urlSchema(r TypeRef) map[string]any {
switch {
case r.Ref != "":
td := p.types[r.Ref]
if td == nil {
return nil
}
if td.Text {
return map[string]any{"type": "string"}
}
switch td.Kind {
case "struct", "map":
return nil
case "list":
if item := p.urlSchema(*td.Elem); item != nil {
return map[string]any{"type": "array", "items": item}
}
return nil
}
if td.Repr == "bytes" || strings.HasPrefix(td.Repr, "bytes_fixed") {
return nil
}
return p.schema(r, nil)
case r.List != nil:
if item := p.urlSchema(*r.List); item != nil {
return map[string]any{"type": "array", "items": item}
}
return nil
case r.Map != nil:
return nil
}
switch r.Prim {
case "string", "bool", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "f32", "f64":
return primSchema(r.Prim)
}
return nil
}
// headerFields is [headerFields] over a described input.
func (p *projector) headerFields(id string) []headerField {
td := p.types[id]
if td == nil || td.Kind != "struct" {
return nil
}
var out []headerField
for _, f := range td.Fields {
if f.Header == "" {
continue
}
out = append(out, headerField{
header: f.Header,
field: f.JSON,
required: f.Required,
schema: p.schema(f.Type, nil),
})
}
return out
}
// urlName is the name a URL carries a field under.
func urlName(f FieldDesc) string {
if f.URL != "" {
return f.URL
}
return f.JSON
}
// statuses is [declaredStatuses] without an op to read it off.
func statuses(declared []int, dflt int) []int {
if len(declared) == 0 {
return []int{dflt}
}
return declared
}
// headerDecls is [responseHeaderSchemas] over a described op.
func headerDecls(op ManifestOp) map[string]any {
if len(op.ResponseHeaders) == 0 {
return nil
}
out := make(map[string]any, len(op.ResponseHeaders))
for _, name := range op.ResponseHeaders {
out[name] = map[string]any{
"description": "Set by " + op.Method + " " + op.Path + ".",
"schema": map[string]any{"type": "string"},
}
}
return out
}
// primSchema is [schemaOf]'s answer for a primitive, spelled from the manifest's
// vocabulary rather than from a reflect.Kind.
func primSchema(prim string) map[string]any {
switch prim {
case "bool":
return map[string]any{"type": "boolean"}
case "string":
return map[string]any{"type": "string"}
case "bytes":
return map[string]any{"type": "string", "contentEncoding": "base64"}
case "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64":
return map[string]any{"type": "integer", "format": intFormat(prim)}
case "f32":
return map[string]any{"type": "number", "format": "float"}
case "f64":
return map[string]any{"type": "number", "format": "double"}
}
if n, ok := fixedLen(prim); ok {
return map[string]any{
"type": "array",
"items": map[string]any{"type": "integer", "format": "uint8"},
// The width is the type, so it is stated rather than left to a reader
// to infer from a name it does not parse.
"minItems": n, "maxItems": n,
}
}
return map[string]any{"type": "object"}
}
// intFormat is the document's spelling of an integer's width and sign, which is
// what a fixed layout reads and "integer" alone does not carry.
func intFormat(prim string) string {
switch prim {
case "i64":
return "int64"
case "i32":
return "int32"
case "i16":
return "int16"
case "i8":
return "int8"
}
return strings.Replace(prim, "u", "uint", 1)
}
// fixedLen reads N out of bytes_fixed[N].
func fixedLen(prim string) (int, bool) {
if !strings.HasPrefix(prim, "bytes_fixed[") || !strings.HasSuffix(prim, "]") {
return 0, false
}
n, err := strconv.Atoi(prim[len("bytes_fixed[") : len(prim)-1])
return n, err == nil
}
// decode reads a schema a type stated for itself. A fresh copy every time, so
// one field's constraints cannot leak into another's through a shared map.
func decode(raw json.RawMessage) map[string]any {
var m map[string]any
if json.Unmarshal(raw, &m) != nil || m == nil {
return map[string]any{}
}
return m
}
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip_test
import (
"encoding/json"
"testing"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/corpus/info"
)
// The manifest lost nothing.
//
// Every projection here is produced twice — once off the Go registry, once off
// the neutral description of it — and compared byte for byte. Equality is what
// says a Rust or C++ front end that fills a manifest in has everything Go has;
// inequality names exactly the field a description cannot yet carry.
func corpus() *zip.App { return (&info.Info{Release: "luxd/1.36.178", Network: 96369}).Ops() }
func same(t *testing.T, what string, a, b any) {
t.Helper()
x, err := json.MarshalIndent(a, "", " ")
if err != nil {
t.Fatalf("%s: %v", what, err)
}
y, err := json.MarshalIndent(b, "", " ")
if err != nil {
t.Fatalf("%s: %v", what, err)
}
if string(x) != string(y) {
t.Errorf("%s: registry and manifest disagree\n--- registry\n%s\n--- manifest\n%s", what, x, y)
}
}
func TestProjectOpenAPIMatchesRegistry(t *testing.T) {
app := corpus()
same(t, "openapi", app.OpenAPISpec(), zip.ProjectOpenAPI(app.Manifest()))
}
func TestProjectMCPMatchesRegistry(t *testing.T) {
app := corpus()
same(t, "mcp", app.MCPTools(), zip.ProjectMCP(app.Manifest()))
}
func TestProjectCLIMatchesRegistry(t *testing.T) {
app := corpus()
same(t, "cli", app.Commands(), zip.ProjectCLI(app.Manifest()))
}
func TestCorpusIsFourteenOps(t *testing.T) {
if n := len(corpus().Registry()); n != 14 {
t.Fatalf("corpus has %d ops, want 14", n)
}
}
func TestProjectZAPMatchesRegistry(t *testing.T) {
app := corpus()
want := zip.ZAPSchema("info", app).String()
got := zip.ProjectZAP("info", app.Manifest()).String()
if want != got {
t.Errorf("zap: registry and manifest disagree\n--- registry\n%s\n--- manifest\n%s", want, got)
}
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip
import (
"strings"
)
// Where a field sits, derived once for every language.
//
// A ZAP field IS an offset and a width, and the rule that assigns them is the
// same rule wherever the type was declared: walk the fields in declaration
// order, align each to its own width, and round the fixed section up to eight.
// Go's encoder derives it at run time from a reflect.Type (internal/zapenc), and
// it has to, because a Go value is encoded reflectively. Nothing else needs to:
// a description already says what each field is, so the offsets follow from the
// description alone.
//
// So the rule lives HERE, once, and the .zap a Rust service publishes is laid
// out by the same code that lays out a Go one. That the two agree with the Go
// ENCODER is not assumed either — TestProjectZAPMatchesRegistry compares this
// against the layout zapenc computes over the same types, which is the only way
// a schema and the bytes it describes stay one thing.
// slot is one field's place in the fixed section.
type slot struct {
at int
repr string // the IDL's spelling: bool, u32, text, bytes, list<...>, bytes_fixed[N]
elem string // a list's element spelling
}
// layout is a struct's fixed section: one slot per field, in declaration order.
type layoutOut struct {
slots []slot
size int
}
// layout assigns td's fields their slots, or names the first field that has no
// fixed form at all.
func (p *projector) layout(td *TypeDesc, inside map[string]bool) (layoutOut, bool) {
if inside[td.ID] {
return layoutOut{}, false // a field is an offset and a width; a recursive type has none.
}
inside[td.ID] = true
defer delete(inside, td.ID)
out := layoutOut{}
at := 0
for _, f := range td.Fields {
s, ok := p.slotOf(f.Type, inside)
if !ok {
return layoutOut{}, false
}
w := slotWidth(s.repr)
a := w
if n, fixed := fixedLen(s.repr); fixed {
a, w = 1, n
}
at = (at + a - 1) &^ (a - 1)
s.at = at
at += w
out.slots = append(out.slots, s)
}
out.size = (at + 7) &^ 7
return out, true
}
// slotOf is one field's spelling and whether it has a fixed form at all.
func (p *projector) slotOf(r TypeRef, inside map[string]bool) (slot, bool) {
switch {
case r.Map != nil:
return slot{}, false // no map in the IDL: a key set is not a layout.
case r.List != nil:
if r.Len > 0 {
// A fixed-length list of anything but bytes has no wire form: an
// element is one value at one width, and a run of them inline is not
// a slot this layout has.
return slot{}, false
}
e, ok := p.elemOf(*r.List, inside)
if !ok {
return slot{}, false
}
return slot{repr: "list<" + e + ">", elem: e}, true
case r.Ref != "":
td := p.types[r.Ref]
if td == nil {
return slot{}, false
}
switch td.Kind {
case "struct":
// A nested value crosses as a complete message in an {offset,length}
// slot, so it is `bytes` here — and only if it lays out at all.
if _, ok := p.layout(td, inside); !ok {
return slot{}, false
}
return slot{repr: "bytes"}, true
case "list":
if td.Len > 0 {
return slot{}, false
}
e, ok := p.elemOf(*td.Elem, inside)
if !ok {
return slot{}, false
}
return slot{repr: "list<" + e + ">", elem: e}, true
case "map":
return slot{}, false
}
return primSlot(td.Repr)
}
return primSlot(r.Prim)
}
// elemOf is a list element's spelling. A list of lists has no form: the element
// of a list is one value at one width, and a list is neither.
func (p *projector) elemOf(r TypeRef, inside map[string]bool) (string, bool) {
s, ok := p.slotOf(r, inside)
if !ok || strings.HasPrefix(s.repr, "list<") {
return "", false
}
return s.repr, true
}
// primSlot is a primitive's spelling, or a refusal for one that has none.
func primSlot(prim string) (slot, bool) {
switch prim {
case "bool", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "f32", "f64", "bytes", "text":
return slot{repr: prim}, true
case "string":
return slot{repr: "text"}, true
}
if _, ok := fixedLen(prim); ok {
return slot{repr: prim}, true
}
return slot{}, false
}
// slotWidth is the fixed-area size of one spelling. Everything variable — text,
// bytes, a list, a nested value — carries an offset and a length.
func slotWidth(repr string) int {
switch {
case repr == "bool" || repr == "i8" || repr == "u8":
return 1
case repr == "i16" || repr == "u16":
return 2
case repr == "i32" || repr == "u32" || repr == "f32":
return 4
}
if n, ok := fixedLen(repr); ok {
return n
}
return 8
}
+287
View File
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip
import (
"sort"
"strconv"
"strings"
)
// The ZAP IDL, read off a [Manifest].
//
// Projection #7, and the one that is allowed to REFUSE: a field with no fixed
// form is not silently dropped, it is named in the ledger under the reason it
// cannot cross. That is why the IDL is an output and never the input — a
// description that could not state a map could not report one either, and the
// unrepresentable field would become undeclarable instead of published.
//
// The offsets are not computed here. They come from the manifest, where the
// front end put them, because the front end is also what generates the
// accessors that read against them: Go derives them at run time from the type,
// Rust and C++ derive them at compile time. One rule, three places it is
// applied, and a byte comparison of the three schemas is what proves they
// applied it the same way.
// ProjectZAP is m as a ZAP schema.
func ProjectZAP(pkg string, ms ...Manifest) *Schema {
s := &Schema{Package: pkg}
e := &idl{schema: s, named: map[string]string{}, taken: map[string]bool{}}
for i, m := range ms {
e.types = newProjector(m)
ops := append([]ManifestOp(nil), m.Ops...)
sort.Slice(ops, func(i, j int) bool { return ops[i].ID < ops[j].ID })
name := m.App
if name == "" {
if len(ms) == 1 {
name = pkg
} else {
name = "app" + strconv.Itoa(i)
}
}
iface := &Interface{Name: idlName(name)}
e.iface = iface
e.methods = map[string]bool{}
for _, op := range ops {
if idlName(op.ID) == op.ID {
e.methods[op.ID] = true
}
}
for _, op := range ops {
e.method(op)
}
if len(iface.Methods) > 0 {
s.Interfaces = append(s.Interfaces, iface)
}
}
sortSchema(s)
return s
}
// idl carries one file's naming state while the manifests are walked.
type idl struct {
schema *Schema
types *projector
named map[string]string // type id → the name it is declared under, "" for refused
taken map[string]bool
iface *Interface
methods map[string]bool
op string
}
func (e *idl) method(op ManifestOp) {
e.op = op.ID
req, reqOK := e.payload(op.In)
rep, repOK := e.payload(op.Out)
if !reqOK || !repOK {
return
}
e.iface.Methods = append(e.iface.Methods, &Method{
Name: e.methodName(op.ID), Request: req, Reply: rep, Doc: op.Description,
})
}
func (e *idl) methodName(id string) string {
if name := idlName(id); name == id {
return id
}
name := idlName(id)
for n := 2; e.methods[name]; n++ {
name = idlName(id) + strconv.Itoa(n)
}
e.methods[name] = true
e.schema.Renamed = append(e.schema.Renamed, Rename{Op: id, Method: name})
return name
}
// payload names the struct one direction carries, "" for a direction that
// carries nothing. The bool is whether it could be expressed at all.
func (e *idl) payload(id string) (string, bool) {
td := e.types.types[id]
if td == nil || td.Kind != "struct" || len(td.Fields) == 0 {
return "", true
}
name := e.define(td)
if name == "" {
return "", false
}
return name, true
}
// define declares td and returns its name, or "" when it cannot be declared.
func (e *idl) define(td *TypeDesc) string {
if name, ok := e.named[td.ID]; ok {
if name == "" {
e.gap(td.Name, td.Spell, CauseReaches) // re-blame for THIS op
}
return name
}
lay, ok := e.types.layout(td, map[string]bool{})
if !ok {
e.named[td.ID] = ""
e.diagnose(td)
return ""
}
if len(lay.slots) == 0 {
e.named[td.ID] = ""
e.gap(td.Name, td.Spell, CauseEmpty)
return ""
}
name := e.name(td)
e.named[td.ID] = name
e.taken[name] = true
fields := make([]Field, 0, len(lay.slots))
for i, s := range lay.slots {
f := td.Fields[i]
fields = append(fields, Field{Name: idlName(f.Name), Type: s.repr, Offset: s.at})
e.opacity(name, f, s)
if strings.HasPrefix(s.repr, "bytes_fixed[") || strings.HasPrefix(s.elem, "bytes_fixed[") {
e.schema.Coded = append(e.schema.Coded, Coded{Struct: name, Field: f.Name, Type: s.repr})
}
e.dropped(name, f)
}
e.schema.Structs = append(e.schema.Structs, &Struct{
Name: name, Fields: fields, Size: lay.size, From: td.Spell,
})
return name
}
// diagnose names EVERY field that cannot cross, and why.
//
// The layout stops at the first field it refuses, which is the answer an encoder
// needs; a work list has to name them all, or it understates the migration by
// however many problems each type has.
func (e *idl) diagnose(td *TypeDesc) {
for _, f := range td.Fields {
if _, ok := e.types.slotOf(f.Type, map[string]bool{}); ok {
continue // this field is fine; another one is why the type refused.
}
e.gap(td.Name+"."+f.Name, f.Spell, e.cause(f.Type))
}
}
// cause is why a field has no fixed form, from the vocabulary the ledger groups
// by. It asks the description, which is what the front end's own layout already
// refused on.
func (e *idl) cause(r TypeRef) string {
switch {
case r.Map != nil:
return CauseMap
case r.List != nil:
return e.cause(*r.List)
case r.Ref != "":
td := e.types.types[r.Ref]
if td == nil {
return CauseAny
}
switch td.Kind {
case "map":
return CauseMap
case "struct":
return CauseReaches
}
return CauseUnwirable
case r.Prim == "any":
return CauseAny
}
return CauseUnwirable
}
// opacity records a field whose bytes are exact and whose TYPE NAME is lost: a
// nested value crosses as `bytes`, so the schema says something is there and
// not what it is.
func (e *idl) opacity(decl string, f FieldDesc, s slot) {
inner := func(r TypeRef) *TypeDesc {
if r.Ref == "" {
return nil
}
return e.types.types[r.Ref]
}
switch {
case s.repr == "bytes":
if td := inner(f.Type); td != nil && td.Kind == "struct" {
e.schema.Opaque = append(e.schema.Opaque, Opacity{Struct: decl, Field: f.Name, Go: td.Spell})
}
case s.elem == "bytes" && f.Type.List != nil:
if td := inner(*f.Type.List); td != nil && td.Kind == "struct" {
e.schema.Opaque = append(e.schema.Opaque, Opacity{Struct: decl, Field: f.Name, Go: td.Spell, List: true})
}
}
}
// dropped names a field whose VALUE does not cross on a type the layout
// accepted: a nested value with no slots of its own crosses as an empty message.
func (e *idl) dropped(decl string, f FieldDesc) {
r := f.Type
if r.List != nil {
r = *r.List
}
td := e.types.types[r.Ref]
if td == nil || td.Kind != "struct" || len(td.Fields) > 0 {
return
}
e.schema.Dropped = append(e.schema.Dropped, Loss{
Struct: decl, Field: f.Name, Go: td.Spell, Cause: LossEmpty,
})
}
// name is the IDL name for a described type: its own name, qualified by its
// package when a different type already holds that name.
func (e *idl) name(td *TypeDesc) string {
base := idlName(td.Name)
if base == "" || base == "_" {
base = idlName(e.op) + "_anon"
}
if !e.taken[base] {
return base
}
if p := td.Pkg; p != "" {
base = idlName(p[strings.LastIndexByte(p, '/')+1:]) + "_" + base
}
for name, n := base, 2; ; n++ {
if !e.taken[name] {
return name
}
name = base + strconv.Itoa(n)
}
}
func (e *idl) gap(field, spell, cause string) {
e.schema.Gaps = append(e.schema.Gaps, Gap{Op: e.op, Field: field, Go: spell, Cause: cause})
}
// sortSchema puts a schema in the one order it renders in, so the same program
// writes the same bytes every run.
func sortSchema(s *Schema) {
sort.Slice(s.Structs, func(i, j int) bool { return s.Structs[i].Name < s.Structs[j].Name })
sort.Slice(s.Interfaces, func(i, j int) bool { return s.Interfaces[i].Name < s.Interfaces[j].Name })
sort.Slice(s.Gaps, func(i, j int) bool {
if s.Gaps[i].Op != s.Gaps[j].Op {
return s.Gaps[i].Op < s.Gaps[j].Op
}
return s.Gaps[i].Field < s.Gaps[j].Field
})
sort.Slice(s.Opaque, func(i, j int) bool {
if s.Opaque[i].Struct != s.Opaque[j].Struct {
return s.Opaque[i].Struct < s.Opaque[j].Struct
}
return s.Opaque[i].Field < s.Opaque[j].Field
})
sort.Slice(s.Dropped, func(i, j int) bool {
if s.Dropped[i].Struct != s.Dropped[j].Struct {
return s.Dropped[i].Struct < s.Dropped[j].Struct
}
return s.Dropped[i].Field < s.Dropped[j].Field
})
sort.Slice(s.Coded, func(i, j int) bool {
if s.Coded[i].Struct != s.Coded[j].Struct {
return s.Coded[i].Struct < s.Coded[j].Struct
}
return s.Coded[i].Field < s.Coded[j].Field
})
}
+61
View File
@@ -0,0 +1,61 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "info"
version = "0.1.0"
dependencies = [
"zip",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zip"
version = "0.1.0"
dependencies = [
"zip-macros",
]
[[package]]
name = "zip-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+8
View File
@@ -0,0 +1,8 @@
[workspace]
resolver = "2"
members = ["zip", "zip-macros", "examples/info"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "BSD-3-Clause-Eco"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "info"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
zip = { path = "../../zip" }
+32
View File
@@ -0,0 +1,32 @@
# The build, in the order the facts become available.
#
# A proc-macro writes its fragment of the manifest as the crate compiles, so the
# description exists only after a compile — which is why projecting it is a
# second step and not a build script. 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
# make wire the ZAP accessors for these ops, for a client that wants them
ZIPC ?= zipc
ZAPGEN ?= zapgen
ROOT := ../..
gen:
cd $(ROOT) && cargo build -p info
$(ZIPC) -out gen -package info target/zip
cd $(ROOT) && cargo build -p info
wire:
$(ZAPGEN) -lang rust -out $(OUT) gen/info.zap
run:
cd $(ROOT) && cargo run -p info
check:
cd $(ROOT) && cargo test -p info
OUT ?= wire
+200
View File
@@ -0,0 +1,200 @@
[
{
"Service": "chain",
"Name": "bootstrapped-get",
"OperationID": "get_chain_bootstrapped",
"Summary": "Bootstrapped reports whether a chain has finished bootstrapping on this node.",
"Description": "Bootstrapped reports whether a chain has finished bootstrapping on this node.",
"Method": "GET",
"Path": "/chain/bootstrapped",
"Args": [],
"Flags": [
{
"Name": "chain",
"Field": "chain",
"Type": "string",
"Help": "Chain is the alias or id of the chain to ask about.",
"Required": true
}
],
"Example": {
"chain": "X"
}
},
{
"Service": "chain",
"Name": "id-get",
"OperationID": "get_chain_id",
"Summary": "ChainID resolves a chain's alias to the id it names.",
"Description": "ChainID resolves a chain's alias to the id it names.",
"Method": "GET",
"Path": "/chain/id",
"Args": [],
"Flags": [
{
"Name": "alias",
"Field": "alias",
"Type": "string",
"Help": "Alias is the name the chain answers to, such as \"X\".",
"Required": true
}
],
"Example": {
"alias": "X"
}
},
{
"Service": "chains",
"Name": "list",
"OperationID": "get_chains",
"Summary": "Chains are the chains this node is running, which is a subset of the chains the P-Chain knows about.",
"Description": "Chains are the chains this node is running, which is a subset of the chains\nthe P-Chain knows about.",
"Method": "GET",
"Path": "/chains",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "fees",
"Name": "list",
"OperationID": "get_fees",
"Summary": "Fees are the transaction fees this node charges, in nLUX.",
"Description": "Fees are the transaction fees this node charges, in nLUX.",
"Method": "GET",
"Path": "/fees",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "lps",
"Name": "list",
"OperationID": "get_lps",
"Summary": "LPs is where the network's stake stands on every current Lux Proposal.",
"Description": "LPs is where the network's stake stands on every current Lux Proposal.",
"Method": "GET",
"Path": "/lps",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "network",
"Name": "id-get",
"OperationID": "get_network_id",
"Summary": "NetworkID is the number of the network this node is on.",
"Description": "NetworkID is the number of the network this node is on.",
"Method": "GET",
"Path": "/network/id",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "network",
"Name": "name-get",
"OperationID": "get_network_name",
"Summary": "NetworkName is the name of the network this node is on.",
"Description": "NetworkName is the name of the network this node is on.",
"Method": "GET",
"Path": "/network/name",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "node",
"Name": "id-get",
"OperationID": "get_node_id",
"Summary": "Identity is this node's id, with the proof it holds the staking key that id is derived from.",
"Description": "Identity is this node's id, with the proof it holds the staking key that id is\nderived from.",
"Method": "GET",
"Path": "/node/id",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "node",
"Name": "ip-get",
"OperationID": "get_node_ip",
"Summary": "Address is where this node tells its peers to reach it.",
"Description": "Address is where this node tells its peers to reach it.",
"Method": "GET",
"Path": "/node/ip",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "node",
"Name": "version-get",
"OperationID": "get_node_version",
"Summary": "NodeVersion is what this node is running: its own release, the database format it reads, and the VM protocol it speaks.",
"Description": "NodeVersion is what this node is running: its own release, the database format\nit reads, and the VM protocol it speaks.",
"Method": "GET",
"Path": "/node/version",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "peers",
"Name": "list",
"OperationID": "get_peers",
"Summary": "Peers are the nodes this one is connected to.",
"Description": "Peers are the nodes this one is connected to. A list of node ids narrows the\nanswer to those, and no list asks for all of them.",
"Method": "GET",
"Path": "/peers",
"Args": [],
"Flags": [
{
"Name": "node-i-ds",
"Field": "nodeIDs",
"Type": "json",
"Help": "NodeIDs are the peers to answer for; none asks for all of them.",
"Required": false
}
],
"Example": {
"nodeIDs": []
}
},
{
"Service": "upgrades",
"Name": "list",
"OperationID": "get_upgrades",
"Summary": "Upgrades is the upgrade schedule this node runs.",
"Description": "Upgrades is the upgrade schedule this node runs.",
"Method": "GET",
"Path": "/upgrades",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "uptime",
"Name": "list",
"OperationID": "get_uptime",
"Summary": "Uptime is how much of the network, by stake, reports having seen this node up.",
"Description": "Uptime is how much of the network, by stake, reports having seen this node up.",
"Method": "GET",
"Path": "/uptime",
"Args": [],
"Flags": null,
"Example": null
},
{
"Service": "vms",
"Name": "list",
"OperationID": "get_vms",
"Summary": "VMs are the virtual machines installed on this node, and the feature extensions its UTXO chains understand.",
"Description": "VMs are the virtual machines installed on this node, and the feature\nextensions its UTXO chains understand.",
"Method": "GET",
"Path": "/vms",
"Args": [],
"Flags": null,
"Example": null
}
]
+118
View File
@@ -0,0 +1,118 @@
# 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 info
struct Bootstrapped {
is_bootstrapped bool @0
}
struct BootstrappedArgs {
chain text @0
}
struct ChainAlias {
alias text @0
}
struct ChainID {
blockchain_id text @0
}
struct Chains {
chains list<bytes> @0
}
struct Fees {
tx_fee u64 @0
create_asset_tx_fee u64 @8
create_chain_tx_fee u64 @16
}
struct NetworkID {
network_id u32 @0
}
struct NetworkName {
network_name text @0
}
struct NodeID {
node_id text @0
node_pop bytes @8
}
struct NodeIP {
ip text @0
}
struct NodeVersion {
version text @0
database_version text @8
rpc_protocol_version u32 @16
git_commit text @24
}
struct Peers {
num_peers u64 @0
peers list<bytes> @8
}
struct PeersArgs {
node_ids list<text> @0
}
struct Upgrades {
stop_vertex_id text @0
epoch_duration u64 @8
}
struct Uptime {
rewarding_stake_percentage text @0
weighted_average_percentage text @8
}
interface info {
# Bootstrapped reports whether a chain has finished bootstrapping on this node.
get_chain_bootstrapped(req: BootstrappedArgs) returns (rep: Bootstrapped)
# ChainID resolves a chain's alias to the id it names.
get_chain_id(req: ChainAlias) returns (rep: ChainID)
# Chains are the chains this node is running, which is a subset of the chains
# the P-Chain knows about.
get_chains() returns (rep: Chains)
# Fees are the transaction fees this node charges, in nLUX.
get_fees() returns (rep: Fees)
# NetworkID is the number of the network this node is on.
get_network_id() returns (rep: NetworkID)
# NetworkName is the name of the network this node is on.
get_network_name() returns (rep: NetworkName)
# Identity is this node's id, with the proof it holds the staking key that id is
# derived from.
get_node_id() returns (rep: NodeID)
# Address is where this node tells its peers to reach it.
get_node_ip() returns (rep: NodeIP)
# NodeVersion is what this node is running: its own release, the database format
# it reads, and the VM protocol it speaks.
get_node_version() returns (rep: NodeVersion)
# Peers are the nodes this one is connected to. A list of node ids narrows the
# answer to those, and no list asks for all of them.
get_peers(req: PeersArgs) returns (rep: Peers)
# Upgrades is the upgrade schedule this node runs.
get_upgrades() returns (rep: Upgrades)
# Uptime is how much of the network, by stake, reports having seen this node up.
get_uptime() returns (rep: Uptime)
}
# ---------------------------------------------------------------------
# 12 op(s) here. What follows is what this schema does not carry.
#
# blocked (3) — the op is absent; the field has no wire form:
# get_lps LPs.lps HashMap<String, LP> (map)
# get_vms VMs.fxs HashMap<String, String> (map)
# get_vms VMs.vms HashMap<String, Vec<String>> (map)
#
# opaque (3) — crosses, arrives without its name:
# Chains.chains Chain (list element)
# NodeID.node_pop ProofOfPossession
# Peers.peers Peer (list element)
+180
View File
@@ -0,0 +1,180 @@
[
{
"annotations": {
"readOnlyHint": true
},
"description": "Bootstrapped reports whether a chain has finished bootstrapping on this node.",
"inputSchema": {
"properties": {
"chain": {
"description": "Chain is the alias or id of the chain to ask about.",
"type": "string"
}
},
"required": [
"chain"
],
"type": "object"
},
"name": "get_chain_bootstrapped"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "ChainID resolves a chain's alias to the id it names.",
"inputSchema": {
"properties": {
"alias": {
"description": "Alias is the name the chain answers to, such as \"X\".",
"type": "string"
}
},
"required": [
"alias"
],
"type": "object"
},
"name": "get_chain_id"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "Chains are the chains this node is running, which is a subset of the chains\nthe P-Chain knows about.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_chains"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "Fees are the transaction fees this node charges, in nLUX.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_fees"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "LPs is where the network's stake stands on every current Lux Proposal.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_lps"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "NetworkID is the number of the network this node is on.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_network_id"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "NetworkName is the name of the network this node is on.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_network_name"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "Identity is this node's id, with the proof it holds the staking key that id is\nderived from.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_node_id"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "Address is where this node tells its peers to reach it.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_node_ip"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "NodeVersion is what this node is running: its own release, the database format\nit reads, and the VM protocol it speaks.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_node_version"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "Peers are the nodes this one is connected to. A list of node ids narrows the\nanswer to those, and no list asks for all of them.",
"inputSchema": {
"properties": {
"nodeIDs": {
"description": "NodeIDs are the peers to answer for; none asks for all of them.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"name": "get_peers"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "Upgrades is the upgrade schedule this node runs.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_upgrades"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "Uptime is how much of the network, by stake, reports having seen this node up.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_uptime"
},
{
"annotations": {
"readOnlyHint": true
},
"description": "VMs are the virtual machines installed on this node, and the feature\nextensions its UTXO chains understand.",
"inputSchema": {
"properties": {},
"type": "object"
},
"name": "get_vms"
}
]
+680
View File
@@ -0,0 +1,680 @@
{
"components": {
"schemas": {
"Bootstrapped": {
"properties": {
"isBootstrapped": {
"description": "IsBootstrapped is true once the chain has caught up.",
"type": "boolean"
}
},
"type": "object"
},
"Chain": {
"properties": {
"bootstrapped": {
"description": "Bootstrapped is whether this node has caught the chain up.",
"type": "boolean"
},
"id": {
"description": "ID is the chain's id.",
"type": "string"
},
"name": {
"description": "Name is the chain's name.",
"type": "string"
},
"vmID": {
"description": "VMID is the id of the virtual machine the chain runs.",
"type": "string"
}
},
"type": "object"
},
"ChainID": {
"properties": {
"blockchainID": {
"description": "BlockchainID is the chain's id.",
"type": "string"
}
},
"type": "object"
},
"Chains": {
"properties": {
"chains": {
"description": "Chains are the chains this node is running.",
"items": {
"$ref": "#/components/schemas/Chain"
},
"type": "array"
}
},
"type": "object"
},
"Fees": {
"properties": {
"createAssetTxFee": {
"description": "CreateAssetTxFee is the fee to create an asset.",
"pattern": "^[0-9]+$",
"type": "string"
},
"createChainTxFee": {
"description": "CreateChainTxFee is the fee to create a chain.",
"pattern": "^[0-9]+$",
"type": "string"
},
"txFee": {
"description": "TxFee is the fee for an ordinary transaction.",
"pattern": "^[0-9]+$",
"type": "string"
}
},
"type": "object"
},
"LP": {
"properties": {
"abstainWeight": {
"description": "AbstainWeight is the stake that has not said.",
"pattern": "^[0-9]+$",
"type": "string"
},
"objectWeight": {
"description": "ObjectWeight is the stake against.",
"pattern": "^[0-9]+$",
"type": "string"
},
"supportWeight": {
"description": "SupportWeight is the stake in favour.",
"pattern": "^[0-9]+$",
"type": "string"
}
},
"type": "object"
},
"LPs": {
"properties": {
"lps": {
"additionalProperties": {
"$ref": "#/components/schemas/LP"
},
"description": "LPs are the proposals, by number.",
"type": "object"
}
},
"type": "object"
},
"NetworkID": {
"properties": {
"networkID": {
"description": "NetworkID is the network's number.",
"format": "uint32",
"type": "integer"
}
},
"type": "object"
},
"NetworkName": {
"properties": {
"networkName": {
"description": "NetworkName is the network's name.",
"type": "string"
}
},
"type": "object"
},
"NodeID": {
"properties": {
"nodeID": {
"description": "NodeID is the id peers know this node by.",
"type": "string"
},
"nodePOP": {
"$ref": "#/components/schemas/ProofOfPossession",
"description": "NodePOP is the proof this node holds the key its id is derived from."
}
},
"type": "object"
},
"NodeIP": {
"properties": {
"ip": {
"description": "IP is the address and port peers dial.",
"type": "string"
}
},
"type": "object"
},
"NodeVersion": {
"properties": {
"databaseVersion": {
"description": "DatabaseVersion is the database format this node reads.",
"type": "string"
},
"gitCommit": {
"description": "GitCommit is the commit this node was built from.",
"type": "string"
},
"rpcProtocolVersion": {
"description": "RPCProtocolVersion is the VM protocol this node speaks.",
"format": "uint32",
"type": "integer"
},
"version": {
"description": "Version is the node's own release.",
"type": "string"
}
},
"type": "object"
},
"Peer": {
"properties": {
"ip": {
"description": "IP is the address this node reaches the peer at.",
"type": "string"
},
"nodeID": {
"description": "NodeID is the peer's id.",
"type": "string"
},
"observedUptime": {
"description": "ObservedUptime is the share of time this node has seen the peer up.",
"format": "uint32",
"type": "integer"
},
"publicIP": {
"description": "PublicIP is the address the peer tells others to use.",
"type": "string"
},
"trackedChains": {
"description": "TrackedChains are the chains the peer is following.",
"items": {
"type": "string"
},
"type": "array"
},
"version": {
"description": "Version is the release the peer is running.",
"type": "string"
}
},
"type": "object"
},
"Peers": {
"properties": {
"numPeers": {
"description": "NumPeers is how many connections this node holds.",
"pattern": "^[0-9]+$",
"type": "string"
},
"peers": {
"description": "Peers are the connections themselves.",
"items": {
"$ref": "#/components/schemas/Peer"
},
"type": "array"
}
},
"type": "object"
},
"ProofOfPossession": {
"properties": {
"proofOfPossession": {
"description": "ProofOfPossession is the signature over that key, hex encoded.",
"type": "string"
},
"publicKey": {
"description": "PublicKey is the BLS public key, hex encoded.",
"type": "string"
}
},
"type": "object"
},
"Upgrades": {
"properties": {
"epochDuration": {
"description": "EpochDuration is how long one epoch lasts, in nanoseconds.",
"pattern": "^[0-9]+$",
"type": "string"
},
"stopVertexID": {
"description": "StopVertexID is the vertex the linearisation stopped at.",
"type": "string"
}
},
"type": "object"
},
"Uptime": {
"properties": {
"rewardingStakePercentage": {
"description": "RewardingStakePercentage is the share of stake that would reward this node.",
"type": "string"
},
"weightedAveragePercentage": {
"description": "WeightedAveragePercentage is the stake-weighted average uptime.",
"type": "string"
}
},
"type": "object"
},
"VMs": {
"properties": {
"fxs": {
"additionalProperties": {
"type": "string"
},
"description": "Fxs are the feature extensions the UTXO chains understand, by id.",
"type": "object"
},
"vms": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"description": "VMs are the installed machines, by id, each with the names it answers to.",
"type": "object"
}
},
"type": "object"
}
}
},
"info": {
"description": "What a Lux node tells anyone who asks: what it is running, what network it is on, who it is connected to, and what its chains cost.",
"title": "Lux node info",
"version": "1.0.0"
},
"openapi": "3.1.0",
"paths": {
"/chain/bootstrapped": {
"get": {
"description": "Bootstrapped reports whether a chain has finished bootstrapping on this node.",
"operationId": "get_chain_bootstrapped",
"parameters": [
{
"description": "Chain is the alias or id of the chain to ask about.",
"example": "X",
"in": "query",
"name": "chain",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"isBootstrapped": true
},
"schema": {
"$ref": "#/components/schemas/Bootstrapped"
}
}
},
"description": "ok"
}
},
"summary": "Bootstrapped reports whether a chain has finished bootstrapping on this node."
}
},
"/chain/id": {
"get": {
"description": "ChainID resolves a chain's alias to the id it names.",
"operationId": "get_chain_id",
"parameters": [
{
"description": "Alias is the name the chain answers to, such as \"X\".",
"example": "X",
"in": "query",
"name": "alias",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"blockchainID": "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM"
},
"schema": {
"$ref": "#/components/schemas/ChainID"
}
}
},
"description": "ok"
}
},
"summary": "ChainID resolves a chain's alias to the id it names."
}
},
"/chains": {
"get": {
"description": "Chains are the chains this node is running, which is a subset of the chains\nthe P-Chain knows about.",
"operationId": "get_chains",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"chains": [
{
"id": "11111111111111111111111111111111LpoYY",
"name": "P-Chain",
"vmID": "11111111111111111111111111111111LpoYY",
"bootstrapped": true
}
]
},
"schema": {
"$ref": "#/components/schemas/Chains"
}
}
},
"description": "ok"
}
},
"summary": "Chains are the chains this node is running, which is a subset of the chains the P-Chain knows about."
}
},
"/fees": {
"get": {
"description": "Fees are the transaction fees this node charges, in nLUX.",
"operationId": "get_fees",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"txFee": "1000000",
"createAssetTxFee": "10000000",
"createChainTxFee": "1000000000"
},
"schema": {
"$ref": "#/components/schemas/Fees"
}
}
},
"description": "ok"
}
},
"summary": "Fees are the transaction fees this node charges, in nLUX."
}
},
"/lps": {
"get": {
"description": "LPs is where the network's stake stands on every current Lux Proposal.",
"operationId": "get_lps",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"lps": {
"23": {
"supportWeight": "0",
"objectWeight": "0",
"abstainWeight": "1000000000000"
}
}
},
"schema": {
"$ref": "#/components/schemas/LPs"
}
}
},
"description": "ok"
}
},
"summary": "LPs is where the network's stake stands on every current Lux Proposal."
}
},
"/network/id": {
"get": {
"description": "NetworkID is the number of the network this node is on.",
"operationId": "get_network_id",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"networkID": 96369
},
"schema": {
"$ref": "#/components/schemas/NetworkID"
}
}
},
"description": "ok"
}
},
"summary": "NetworkID is the number of the network this node is on."
}
},
"/network/name": {
"get": {
"description": "NetworkName is the name of the network this node is on.",
"operationId": "get_network_name",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"networkName": "mainnet"
},
"schema": {
"$ref": "#/components/schemas/NetworkName"
}
}
},
"description": "ok"
}
},
"summary": "NetworkName is the name of the network this node is on."
}
},
"/node/id": {
"get": {
"description": "Identity is this node's id, with the proof it holds the staking key that id is\nderived from.",
"operationId": "get_node_id",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"nodeID": "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg",
"nodePOP": {
"publicKey": "0x8f95",
"proofOfPossession": "0x86a3"
}
},
"schema": {
"$ref": "#/components/schemas/NodeID"
}
}
},
"description": "ok"
}
},
"summary": "Identity is this node's id, with the proof it holds the staking key that id is derived from."
}
},
"/node/ip": {
"get": {
"description": "Address is where this node tells its peers to reach it.",
"operationId": "get_node_ip",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"ip": "203.0.113.9:9651"
},
"schema": {
"$ref": "#/components/schemas/NodeIP"
}
}
},
"description": "ok"
}
},
"summary": "Address is where this node tells its peers to reach it."
}
},
"/node/version": {
"get": {
"description": "NodeVersion is what this node is running: its own release, the database format\nit reads, and the VM protocol it speaks.",
"operationId": "get_node_version",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"version": "luxd/1.36.178",
"databaseVersion": "v1.4.5",
"rpcProtocolVersion": 39,
"gitCommit": ""
},
"schema": {
"$ref": "#/components/schemas/NodeVersion"
}
}
},
"description": "ok"
}
},
"summary": "NodeVersion is what this node is running: its own release, the database format it reads, and the VM protocol it speaks."
}
},
"/peers": {
"get": {
"description": "Peers are the nodes this one is connected to. A list of node ids narrows the\nanswer to those, and no list asks for all of them.",
"operationId": "get_peers",
"parameters": [
{
"description": "NodeIDs are the peers to answer for; none asks for all of them.",
"example": [],
"in": "query",
"name": "nodeIDs",
"required": false,
"schema": {
"items": {
"type": "string"
},
"type": "array"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"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": []
}
]
},
"schema": {
"$ref": "#/components/schemas/Peers"
}
}
},
"description": "ok"
}
},
"summary": "Peers are the nodes this one is connected to."
}
},
"/upgrades": {
"get": {
"description": "Upgrades is the upgrade schedule this node runs.",
"operationId": "get_upgrades",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"stopVertexID": "jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga",
"epochDuration": "300000000000"
},
"schema": {
"$ref": "#/components/schemas/Upgrades"
}
}
},
"description": "ok"
}
},
"summary": "Upgrades is the upgrade schedule this node runs."
}
},
"/uptime": {
"get": {
"description": "Uptime is how much of the network, by stake, reports having seen this node up.",
"operationId": "get_uptime",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"rewardingStakePercentage": "100.0000",
"weightedAveragePercentage": "99.9999"
},
"schema": {
"$ref": "#/components/schemas/Uptime"
}
}
},
"description": "ok"
}
},
"summary": "Uptime is how much of the network, by stake, reports having seen this node up."
}
},
"/vms": {
"get": {
"description": "VMs are the virtual machines installed on this node, and the feature\nextensions its UTXO chains understand.",
"operationId": "get_vms",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"vms": {
"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6": [
"platformvm"
]
},
"fxs": {
"spqBHsy2UBGpjaQJezEywUy1AB98eVjJ3WQ38x1vpjsx4xPkY": "secp256k1fx"
}
},
"schema": {
"$ref": "#/components/schemas/VMs"
}
}
},
"description": "ok"
}
},
"summary": "VMs are the virtual machines installed on this node, and the feature extensions its UTXO chains understand."
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! A node's info service: fourteen operations, written once.
pub mod ops;
pub mod types;
/// The projections, as zipc wrote them from what the compiler read. A service
/// publishes the document it implements.
pub const OPENAPI: &str = include_str!("../gen/openapi.json");
pub const MCP: &str = include_str!("../gen/mcp.json");
/// service is this node's info app, with its documents on it.
pub fn service(release: &str, network: u32) -> zip::App {
let mut app = ops::Info {
release: release.to_string(),
network,
}
.ops();
app.serve("/openapi.json", "application/json", OPENAPI);
app.serve("/mcp.json", "application/json", MCP);
app
}
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! A node's info service, serving REST and ZAP from one registry.
//!
//! ```text
//! info --http 127.0.0.1:8000 --zap 127.0.0.1:9653
//! ```
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();
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;
}
other => {
eprintln!("info: unknown argument {other}");
std::process::exit(2);
}
}
}
let app = info::service("luxd/1.36.178", 96369);
println!("info: {} ops", app.ops_len());
for (method, path) in app.routes() {
println!(" {method} {path}");
}
if let Some(a) = &http {
println!("info: http on {a}");
}
if let Some(a) = &zap {
println!("info: zap on {a}");
}
if let Err(e) = zip::listen(app, http.as_deref(), zap.as_deref()) {
eprintln!("info: {e}");
std::process::exit(1);
}
}
+223
View File
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The questions this node answers, and the one place each answer is written.
//!
//! A handler here IS the operation. There is no second copy of it behind a
//! method table: writing it yields the REST route, the ZAP method, the OpenAPI
//! document, the MCP tool, the CLI command and the ZAP schema, all from this one
//! declaration and the doc comment above it.
use std::collections::HashMap;
use crate::types::*;
/// Info answers questions about one node. It holds the answers rather than
/// computing them, because this service exists to be projected, not to run a
/// network.
pub struct Info {
pub release: String,
pub network: u32,
}
#[zip::ops(
app = "info",
title = "Lux node info",
version = "1.0.0",
description = "What a Lux node tells anyone who asks: what it is running, what network it is on, who it is connected to, and what its chains cost."
)]
impl Info {
/// NodeVersion is what this node is running: its own release, the database format
/// it reads, and the VM protocol it speaks.
///
/// Response: {"version": "luxd/1.36.178", "databaseVersion": "v1.4.5", "rpcProtocolVersion": 39, "gitCommit": ""}
#[get("/node/version")]
fn node_version(&self) -> Result<NodeVersion, zip::Error> {
Ok(NodeVersion {
version: self.release.clone(),
database_version: "v1.4.5".into(),
rpc_protocol_version: 39,
git_commit: String::new(),
})
}
/// Identity is this node's id, with the proof it holds the staking key that id is
/// derived from.
///
/// Response: {"nodeID": "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg", "nodePOP": {"publicKey": "0x8f95", "proofOfPossession": "0x86a3"}}
#[get("/node/id")]
fn identity(&self) -> Result<NodeID, zip::Error> {
Ok(NodeID {
node_id: "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg".into(),
node_pop: ProofOfPossession {
public_key: "0x8f95".into(),
proof_of_possession: "0x86a3".into(),
},
})
}
/// Address is where this node tells its peers to reach it.
///
/// Response: {"ip": "203.0.113.9:9651"}
#[get("/node/ip")]
fn address(&self) -> Result<NodeIP, zip::Error> {
Ok(NodeIP {
ip: "203.0.113.9:9651".into(),
})
}
/// NetworkID is the number of the network this node is on.
///
/// Response: {"networkID": 96369}
#[get("/network/id")]
fn network_id(&self) -> Result<NetworkID, zip::Error> {
Ok(NetworkID {
network_id: self.network,
})
}
/// NetworkName is the name of the network this node is on.
///
/// Response: {"networkName": "mainnet"}
#[get("/network/name")]
fn network_name(&self) -> Result<NetworkName, zip::Error> {
Ok(NetworkName {
network_name: "mainnet".into(),
})
}
/// ChainID resolves a chain's alias to the id it names.
///
/// Example: {"alias": "X"}
/// Response: {"blockchainID": "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM"}
#[get("/chain/id")]
fn chain_id(&self, arg: &ChainAlias) -> Result<ChainID, zip::Error> {
if arg.alias.is_empty() {
return Err(zip::Error::bad("argument 'alias' not given"));
}
Ok(ChainID {
blockchain_id: "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM".into(),
})
}
/// Bootstrapped reports whether a chain has finished bootstrapping on this node.
///
/// Example: {"chain": "X"}
/// Response: {"isBootstrapped": true}
#[get("/chain/bootstrapped")]
fn bootstrapped(&self, arg: &BootstrappedArgs) -> Result<Bootstrapped, zip::Error> {
if arg.chain.is_empty() {
return Err(zip::Error::bad("argument 'chain' not given"));
}
Ok(Bootstrapped {
is_bootstrapped: true,
})
}
/// Chains are the chains this node is running, which is a subset of the chains
/// the P-Chain knows about.
///
/// Response: {"chains": [{"id": "11111111111111111111111111111111LpoYY", "name": "P-Chain", "vmID": "11111111111111111111111111111111LpoYY", "bootstrapped": true}]}
#[get("/chains")]
fn chains(&self) -> Result<Chains, zip::Error> {
Ok(Chains {
chains: vec![Chain {
id: "11111111111111111111111111111111LpoYY".into(),
name: "P-Chain".into(),
vm_id: "11111111111111111111111111111111LpoYY".into(),
bootstrapped: true,
}],
})
}
/// Peers are the nodes this one is connected to. A list of node ids narrows the
/// answer to those, and no list asks for all of them.
///
/// 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> {
let peers = vec![Peer {
ip: "203.0.113.9:9651".into(),
public_ip: "203.0.113.9:9651".into(),
node_id: "NodeID-7Xhw2mDxuDS44j42TCB6U5579esbSt3Lg".into(),
version: "luxd/1.36.178".into(),
observed_uptime: 99,
tracked_chains: vec![],
}];
Ok(Peers {
num_peers: Uint64(peers.len() as u64),
peers,
})
}
/// LPs is where the network's stake stands on every current Lux Proposal.
///
/// Response: {"lps": {"23": {"supportWeight": "0", "objectWeight": "0", "abstainWeight": "1000000000000"}}}
#[get("/lps")]
fn lps(&self) -> Result<LPs, zip::Error> {
let mut lps = HashMap::new();
lps.insert(
"23".to_string(),
LP {
abstain_weight: Uint64(1_000_000_000_000),
..Default::default()
},
);
Ok(LPs { lps })
}
/// VMs are the virtual machines installed on this node, and the feature
/// extensions its UTXO chains understand.
///
/// Response: {"vms": {"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6": ["platformvm"]}, "fxs": {"spqBHsy2UBGpjaQJezEywUy1AB98eVjJ3WQ38x1vpjsx4xPkY": "secp256k1fx"}}
#[get("/vms")]
fn vms(&self) -> Result<VMs, zip::Error> {
let mut vms = HashMap::new();
vms.insert(
"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6".to_string(),
vec!["platformvm".to_string()],
);
let mut fxs = HashMap::new();
fxs.insert(
"spqBHsy2UBGpjaQJezEywUy1AB98eVjJ3WQ38x1vpjsx4xPkY".to_string(),
"secp256k1fx".to_string(),
);
Ok(VMs { vms, fxs })
}
/// Upgrades is the upgrade schedule this node runs.
///
/// Response: {"stopVertexID": "jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga", "epochDuration": "300000000000"}
#[get("/upgrades")]
fn upgrades(&self) -> Result<Upgrades, zip::Error> {
Ok(Upgrades {
stop_vertex_id: "jrGWDh5Po9FMj54depyunNixpia5PN4aAYxfmNzU8n752Rjga".into(),
epoch_duration: Uint64(300_000_000_000),
})
}
/// Uptime is how much of the network, by stake, reports having seen this node up.
///
/// Response: {"rewardingStakePercentage": "100.0000", "weightedAveragePercentage": "99.9999"}
#[get("/uptime")]
fn uptime(&self) -> Result<Uptime, zip::Error> {
Ok(Uptime {
rewarding_stake_percentage: "100.0000".into(),
weighted_average_percentage: "99.9999".into(),
})
}
/// Fees are the transaction fees this node charges, in nLUX.
///
/// Response: {"txFee": "1000000", "createAssetTxFee": "10000000", "createChainTxFee": "1000000000"}
#[get("/fees")]
fn fees(&self) -> Result<Fees, zip::Error> {
Ok(Fees {
tx_fee: Uint64(1_000_000),
create_asset_tx_fee: Uint64(10_000_000),
create_chain_tx_fee: Uint64(1_000_000_000),
})
}
}
+265
View File
@@ -0,0 +1,265 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! What a node tells anyone who asks — the conformance corpus, in Rust.
//!
//! This service exists three times: here, in corpus/info (Go) and in
//! cpp/examples/info. The three are written in their own languages, in their
//! own idioms, and each one's framework projects it without help from the other
//! two. The projections are then compared byte for byte.
//!
//! The shapes are chosen to be the ones that break: a quoted decimal (a value
//! whose JSON form is not what it is made of), a nested named struct, a list of
//! structs, a map, and an op that takes nothing at all.
use std::collections::HashMap;
use zip::Wire;
/// Uint64 is a 64-bit count carried as a quoted decimal, because JSON numbers
/// lose precision above 2^53 and a stake does not.
///
/// It is the case that makes the manifest carry two answers for one type: to a
/// JSON reader this is a string, and to a fixed layout it is eight bytes. A
/// description that could only say one of those would make one projection lie.
#[derive(Wire, Clone, Copy, Default, Debug)]
#[zip(text, json = r#"{"pattern":"^[0-9]+$","type":"string"}"#)]
pub struct Uint64(pub u64);
impl From<u64> for Uint64 {
fn from(v: u64) -> Self {
Uint64(v)
}
}
/// NodeVersion is what this node is running.
#[derive(Wire, Default)]
pub struct NodeVersion {
/// Version is the node's own release.
#[zip(json = "version")]
pub version: String,
/// DatabaseVersion is the database format this node reads.
#[zip(json = "databaseVersion")]
pub database_version: String,
/// RPCProtocolVersion is the VM protocol this node speaks.
#[zip(json = "rpcProtocolVersion")]
pub rpc_protocol_version: u32,
/// GitCommit is the commit this node was built from.
#[zip(json = "gitCommit")]
pub git_commit: String,
}
/// NodeID is this node's id, with the proof it holds the staking key.
#[derive(Wire, Default)]
pub struct NodeID {
/// NodeID is the id peers know this node by.
#[zip(json = "nodeID")]
pub node_id: String,
/// NodePOP is the proof this node holds the key its id is derived from.
#[zip(json = "nodePOP")]
pub node_pop: ProofOfPossession,
}
/// ProofOfPossession is a staking key and a signature over it.
#[derive(Wire, Default)]
pub struct ProofOfPossession {
/// PublicKey is the BLS public key, hex encoded.
#[zip(json = "publicKey")]
pub public_key: String,
/// ProofOfPossession is the signature over that key, hex encoded.
#[zip(json = "proofOfPossession")]
pub proof_of_possession: String,
}
/// NodeIP is where this node tells its peers to reach it.
#[derive(Wire, Default)]
pub struct NodeIP {
/// IP is the address and port peers dial.
#[zip(json = "ip")]
pub ip: String,
}
/// NetworkID is the number of the network a node is on.
#[derive(Wire, Default)]
pub struct NetworkID {
/// NetworkID is the network's number.
#[zip(json = "networkID")]
pub network_id: u32,
}
/// NetworkName is the name of the network a node is on.
#[derive(Wire, Default)]
pub struct NetworkName {
/// NetworkName is the network's name.
#[zip(json = "networkName")]
pub network_name: String,
}
/// ChainAlias names the chain a caller is asking about.
#[derive(Wire, Default)]
pub struct ChainAlias {
/// Alias is the name the chain answers to, such as "X".
#[zip(json = "alias", required)]
pub alias: String,
}
/// ChainID is the id an alias names.
#[derive(Wire, Default)]
pub struct ChainID {
/// BlockchainID is the chain's id.
#[zip(json = "blockchainID")]
pub blockchain_id: String,
}
/// BootstrappedArgs names the chain whose bootstrap state is being asked about.
#[derive(Wire, Default)]
pub struct BootstrappedArgs {
/// Chain is the alias or id of the chain to ask about.
#[zip(json = "chain", required)]
pub chain: String,
}
/// Bootstrapped is whether a chain has finished bootstrapping.
#[derive(Wire, Default)]
pub struct Bootstrapped {
/// IsBootstrapped is true once the chain has caught up.
#[zip(json = "isBootstrapped")]
pub is_bootstrapped: bool,
}
/// Chain is one chain this node runs.
#[derive(Wire, Default)]
pub struct Chain {
/// ID is the chain's id.
#[zip(json = "id")]
pub id: String,
/// Name is the chain's name.
#[zip(json = "name")]
pub name: String,
/// VMID is the id of the virtual machine the chain runs.
#[zip(json = "vmID")]
pub vm_id: String,
/// Bootstrapped is whether this node has caught the chain up.
#[zip(json = "bootstrapped")]
pub bootstrapped: bool,
}
/// Chains are the chains this node runs.
#[derive(Wire, Default)]
pub struct Chains {
/// Chains are the chains this node is running.
#[zip(json = "chains")]
pub chains: Vec<Chain>,
}
/// PeersArgs narrows the peer list to the nodes named.
#[derive(Wire, Default)]
pub struct PeersArgs {
/// NodeIDs are the peers to answer for; none asks for all of them.
#[zip(json = "nodeIDs")]
pub node_ids: Vec<String>,
}
/// Peer is one connection this node holds.
#[derive(Wire, Default)]
pub struct Peer {
/// IP is the address this node reaches the peer at.
#[zip(json = "ip")]
pub ip: String,
/// PublicIP is the address the peer tells others to use.
#[zip(json = "publicIP")]
pub public_ip: String,
/// NodeID is the peer's id.
#[zip(json = "nodeID")]
pub node_id: String,
/// Version is the release the peer is running.
#[zip(json = "version")]
pub version: String,
/// ObservedUptime is the share of time this node has seen the peer up.
#[zip(json = "observedUptime")]
pub observed_uptime: u32,
/// TrackedChains are the chains the peer is following.
#[zip(json = "trackedChains")]
pub tracked_chains: Vec<String>,
}
/// Peers are the nodes this one is connected to.
#[derive(Wire, Default)]
pub struct Peers {
/// NumPeers is how many connections this node holds.
#[zip(json = "numPeers")]
pub num_peers: Uint64,
/// Peers are the connections themselves.
#[zip(json = "peers")]
pub peers: Vec<Peer>,
}
/// LP is where the stake stands on one proposal.
#[derive(Wire, Default)]
pub struct LP {
/// SupportWeight is the stake in favour.
#[zip(json = "supportWeight")]
pub support_weight: Uint64,
/// ObjectWeight is the stake against.
#[zip(json = "objectWeight")]
pub object_weight: Uint64,
/// AbstainWeight is the stake that has not said.
#[zip(json = "abstainWeight")]
pub abstain_weight: Uint64,
}
/// LPs is where the network's stake stands on every current proposal.
#[derive(Wire, Default)]
pub struct LPs {
/// LPs are the proposals, by number.
#[zip(json = "lps")]
pub lps: HashMap<String, LP>,
}
/// VMs are the virtual machines installed on this node.
#[derive(Wire, Default)]
pub struct VMs {
/// VMs are the installed machines, by id, each with the names it answers to.
#[zip(json = "vms")]
pub vms: HashMap<String, Vec<String>>,
/// Fxs are the feature extensions the UTXO chains understand, by id.
#[zip(json = "fxs")]
pub fxs: HashMap<String, String>,
}
/// Upgrades is the upgrade schedule this node runs.
#[derive(Wire, Default)]
pub struct Upgrades {
/// StopVertexID is the vertex the linearisation stopped at.
#[zip(json = "stopVertexID")]
pub stop_vertex_id: String,
/// EpochDuration is how long one epoch lasts, in nanoseconds.
#[zip(json = "epochDuration")]
pub epoch_duration: Uint64,
}
/// Uptime is how much of the network reports having seen this node up.
#[derive(Wire, Default)]
pub struct Uptime {
/// RewardingStakePercentage is the share of stake that would reward this node.
#[zip(json = "rewardingStakePercentage")]
pub rewarding_stake_percentage: String,
/// WeightedAveragePercentage is the stake-weighted average uptime.
#[zip(json = "weightedAveragePercentage")]
pub weighted_average_percentage: String,
}
/// Fees are the transaction fees this node charges, in nLUX.
#[derive(Wire, Default)]
pub struct Fees {
/// TxFee is the fee for an ordinary transaction.
#[zip(json = "txFee")]
pub tx_fee: Uint64,
/// CreateAssetTxFee is the fee to create an asset.
#[zip(json = "createAssetTxFee")]
pub create_asset_tx_fee: Uint64,
/// CreateChainTxFee is the fee to create a chain.
#[zip(json = "createChainTxFee")]
pub create_chain_tx_fee: Uint64,
}
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The two doors answer the same ops.
//!
//! A REST request and a ZAP frame reach one handler, so the test asks both and
//! compares what came back. It is the only way to know the claim is true: two
//! doors that each pass their own test can still be two services.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::Arc;
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) = (
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);
}
});
}
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)
}
/// over_http asks one question in text and answers with the body.
fn over_http(addr: &str, target: &str) -> (u16, String) {
let mut c = TcpStream::connect(addr).unwrap();
let req = format!("GET {target} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
c.write_all(req.as_bytes()).unwrap();
let mut all = String::new();
c.read_to_string(&mut all).unwrap();
let status: u16 = all.split_whitespace().nth(1).unwrap().parse().unwrap();
let body = all.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
(status, body)
}
/// over_zap asks the same question in ZAP frames and answers with the body.
fn over_zap(addr: &str, target: &str) -> (u16, String) {
let mut c = TcpStream::connect(addr).unwrap();
let frame = zip::zaphttp::ask("GET", target, &[]);
c.write_all(&(frame.len() as u32).to_le_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];
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,
"not a response frame"
);
let r = zip::wire::Response::wrap(&body).unwrap();
(r.status(), String::from_utf8(r.body().to_vec()).unwrap())
}
#[test]
fn both_doors_answer_the_same_op() {
let (http, zap) = doors();
for target in [
"/node/version",
"/chains",
"/chain/bootstrapped?chain=X",
"/peers",
"/lps",
"/vms",
"/fees",
] {
let a = over_http(&http, target);
let b = over_zap(&zap, target);
assert_eq!(a.0, 200, "http {target}: {}", a.1);
assert_eq!(a, b, "the two doors disagree about {target}");
}
}
/// A quoted decimal is a string on the wire and eight bytes to a layout, and
/// this is the half a reader sees.
#[test]
fn a_count_is_carried_as_text() {
let (http, _) = doors();
let (status, body) = over_http(&http, "/fees");
assert_eq!(status, 200);
assert!(body.contains(r#""txFee":"1000000""#), "{body}");
}
/// The URL is the whole input of a bodyless op, so a query value binds.
#[test]
fn a_query_value_binds() {
let (http, _) = doors();
assert_eq!(
over_http(&http, "/chain/bootstrapped?chain=X"),
(200, r#"{"isBootstrapped":true}"#.to_string())
);
// A field the op declared it cannot run without is refused by the binder,
// in the words the document already used for it, before the handler runs.
let (status, body) = over_http(&http, "/chain/bootstrapped");
assert_eq!(status, 400, "{body}");
assert_eq!(
body,
"{\"detail\":\"field \\\"chain\\\" is required\",\"status\":400,\"title\":\"Bad Request\",\"type\":\"about:blank\"}"
);
}
/// A list rides one URL value, comma-separated, which is what the document
/// already publishes for a repeated parameter.
#[test]
fn a_list_rides_one_value() {
let (http, _) = doors();
let (status, body) = over_http(&http, "/peers?nodeIDs=NodeID-a,NodeID-b");
assert_eq!(status, 200, "{body}");
assert!(body.starts_with(r#"{"numPeers":"1""#), "{body}");
}
/// The service publishes the document it implements.
#[test]
fn the_document_is_served() {
let (http, _) = doors();
let (status, body) = over_http(&http, "/openapi.json");
assert_eq!(status, 200);
assert!(
body.contains(r#""operationId": "get_chain_bootstrapped""#),
"{body}"
);
let (status, tools) = over_http(&http, "/mcp.json");
assert_eq!(status, 200);
assert!(tools.contains(r#""name": "get_fees""#), "{tools}");
}
/// An address nothing registered is a refusal, and a method the address does
/// not answer is a different one.
#[test]
fn an_unknown_address_is_refused() {
let (http, _) = doors();
assert_eq!(over_http(&http, "/nope").0, 404);
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "zip-macros"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Declaring a zip op, in Rust."
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full", "extra-traits"] }
quote = "1"
proc-macro2 = "1"
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! `#[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.
use syn::{Attribute, Expr, Lit, Meta, Token};
#[derive(Default)]
pub struct Attrs {
/// json is the name the body carries this under.
pub json: Option<String>,
/// url is the name a URL carries it under; "-" opts out.
pub url: Option<String>,
/// header is the request header it reads.
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.
pub json_schema: Option<String>,
}
impl Attrs {
pub fn read(attrs: &[Attribute]) -> Result<Self, syn::Error> {
let mut out = Attrs::default();
for a in attrs {
if !a.path().is_ident("zip") {
continue;
}
let items = a.parse_args_with(
syn::punctuated::Punctuated::<Meta, Token![,]>::parse_terminated,
)?;
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("text") => out.text = true,
Meta::NameValue(nv) => {
let v = literal(&nv.value)?;
if nv.path.is_ident("json") {
// On a type, `json` is the schema it states; on a
// field, the name the body carries it under. The two
// never meet: a field has no schema of its own and a
// type has no name on the wire.
if v.trim_start().starts_with('{') {
out.json_schema = Some(v);
} else {
out.json = Some(v);
}
} else if nv.path.is_ident("url") {
out.url = Some(v);
} else if nv.path.is_ident("header") {
out.header = Some(v);
} else {
return Err(syn::Error::new_spanned(
&nv.path,
"zip: known keys are json, url, header, required, skip, text",
));
}
}
other => {
return Err(syn::Error::new_spanned(
other,
"zip: known keys are json, url, header, required, skip, text",
))
}
}
}
}
Ok(out)
}
}
impl Attrs {
/// json is the schema a type stated for itself, if it stated one.
pub fn schema(&self) -> Option<&String> {
self.json_schema.as_ref()
}
}
fn literal(e: &Expr) -> Result<String, syn::Error> {
if let Expr::Lit(l) = e {
if let Lit::Str(s) = &l.lit {
return Ok(s.value());
}
}
Err(syn::Error::new_spanned(e, "zip: expected a string"))
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The prose, from where it is already written.
//!
//! `///` desugars to `#[doc = "…"]` before a macro ever runs, so the comment
//! above a handler reaches the macro as an attribute on the same item as the
//! signature and the path. Rust therefore needs no second pass over the source
//! and no generated file: the sentence a person wrote is read in the one place
//! the op is declared.
//!
//! The two special lines are the ones a document is unusable without. `Example:`
//! is the request a reader can press "try it" on, `Response:` the answer they
//! should get. They are lines of the comment rather than an attribute for the
//! same reason the prose is: one mechanism, one place.
use quote::ToTokens;
use syn::{Attribute, Expr, Lit, Meta, Type};
/// Doc is what a comment said.
pub struct Doc {
/// text is the comment with the Example and Response lines taken out.
pub text: String,
/// example and response are raw JSON, so they land in the document exactly
/// as written and are wrong loudly rather than quietly if malformed.
pub example: String,
pub response: String,
}
/// read pulls the doc comment off an item's attributes.
pub fn read(attrs: &[Attribute]) -> Doc {
let mut prose: Vec<String> = Vec::new();
let mut example = String::new();
let mut response = String::new();
for line in lines(attrs) {
let t = line.trim();
if let Some(rest) = t.strip_prefix("Example:") {
example = rest.trim().to_string();
continue;
}
if let Some(rest) = t.strip_prefix("Response:") {
response = rest.trim().to_string();
continue;
}
prose.push(line);
}
while prose.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
prose.pop();
}
Doc {
text: prose.join("\n").trim().to_string(),
example,
response,
}
}
/// lines is every `#[doc]` line, with the one leading space rustdoc adds
/// removed — the comment as it was typed, not as it was encoded.
fn lines(attrs: &[Attribute]) -> Vec<String> {
let mut out = Vec::new();
for a in attrs {
let Meta::NameValue(nv) = &a.meta else {
continue;
};
if !nv.path.is_ident("doc") {
continue;
}
let Expr::Lit(l) = &nv.value else { continue };
let Lit::Str(s) = &l.lit else { continue };
let v = s.value();
out.push(v.strip_prefix(' ').unwrap_or(&v).to_string());
}
out
}
/// keep is every attribute that is not a doc comment and not one of ours, so
/// the item the macro re-emits is the item the author wrote.
pub fn keep(attrs: &[Attribute], ours: &[&str]) -> Vec<Attribute> {
attrs
.iter()
.filter(|a| !ours.iter().any(|n| a.path().is_ident(n)))
.cloned()
.collect()
}
/// spell is a type as the source writes it, for the ledger a person reads when
/// they go to change the declaration.
pub fn spell(t: &Type) -> String {
let s = t.to_token_stream().to_string();
s.replace(" < ", "<")
.replace(" > ", ">")
.replace(" >", ">")
.replace("< ", "<")
.replace(" ,", ",")
.replace(" ::", "::")
.replace(":: ", "::")
.replace("& ", "&")
.replace(" ;", ";")
}
+106
View File
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! Where the description goes.
//!
//! A macro runs inside the compiler and writes its fragment of the manifest as
//! it expands, so the description of an op exists the moment the op does. There
//! is no second pass over the source, no generated file to check in, and no
//! staleness to gate: what the compiler read is what the projector reads.
//!
//! The fragments land under `ZIP_MANIFEST_DIR`, or `target/zip` beside the
//! crate. `zipc` merges them.
use std::fs;
use std::path::PathBuf;
/// dir is where this crate's fragments go, creating it if it is not there.
pub fn dir(kind: &str) -> Option<PathBuf> {
let base = match std::env::var("ZIP_MANIFEST_DIR") {
Ok(v) if !v.is_empty() => PathBuf::from(v),
_ => PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?)
.join("target")
.join("zip"),
};
let at = base.join(kind);
fs::create_dir_all(&at).ok()?;
Some(at)
}
/// write files one fragment, named so a second expansion of the same item
/// replaces the first rather than adding to it.
pub fn write(kind: &str, name: &str, body: &str) {
if let Some(at) = dir(kind) {
let _ = fs::write(at.join(format!("{name}.json")), body);
}
}
/// quote is a JSON string literal.
pub fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
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('"');
out
}
/// field writes `"name": value` when value is worth writing.
pub struct Object {
body: String,
}
impl Object {
pub fn new() -> Self {
Object {
body: String::from("{"),
}
}
pub fn raw(&mut self, name: &str, value: &str) -> &mut Self {
if self.body.len() > 1 {
self.body.push(',');
}
self.body.push_str(&quote(name));
self.body.push(':');
self.body.push_str(value);
self
}
pub fn text(&mut self, name: &str, value: &str) -> &mut Self {
if value.is_empty() {
return self;
}
self.raw(name, &quote(value))
}
pub fn flag(&mut self, name: &str, value: bool) -> &mut Self {
if !value {
return self;
}
self.raw(name, "true")
}
pub fn number(&mut self, name: &str, value: usize) -> &mut Self {
if value == 0 {
return self;
}
self.raw(name, &value.to_string())
}
pub fn finish(&mut self) -> String {
let mut out = std::mem::take(&mut self.body);
out.push('}');
out
}
}
/// list is a JSON array of already-rendered values.
pub fn list(items: &[String]) -> String {
format!("[{}]", items.join(","))
}
+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.
//! Declaring a zip op, in Rust.
//!
//! Two macros and nothing else. `#[zip::ops]` on an impl block turns the
//! methods carrying a route into a service; `#[derive(zip::Wire)]` on a type
//! makes it say what it is. Both read the doc comments that are already there,
//! so an operation's prose has one home — the source — and reaches the
//! document, the tool list and the command line from it.
mod attr;
mod doc;
mod frag;
mod ops;
mod ty;
mod wire;
use proc_macro::TokenStream;
/// ops turns an impl block into a service.
#[proc_macro_attribute]
pub fn ops(args: TokenStream, item: TokenStream) -> TokenStream {
let block = match syn::parse::<syn::ItemImpl>(item) {
Ok(b) => b,
Err(e) => return e.to_compile_error().into(),
};
match ops::expand(args.into(), block) {
Ok(t) => t.into(),
Err(e) => e.to_compile_error().into(),
}
}
/// Wire makes a type describe itself, and carry itself over JSON.
#[proc_macro_derive(Wire, attributes(zip))]
pub fn derive_wire(item: TokenStream) -> TokenStream {
let input = match syn::parse::<syn::DeriveInput>(item) {
Ok(i) => i,
Err(e) => return e.to_compile_error().into(),
};
match wire::derive(input) {
Ok(t) => t.into(),
Err(e) => e.to_compile_error().into(),
}
}
+326
View File
@@ -0,0 +1,326 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! `#[zip::ops]` — a service's operations, declared where they are written.
//!
//! An impl block is the registry. A method carrying `#[get("/path")]` is an op:
//! its name, its address, the type it takes, the type it answers with and the
//! sentence above it are all on the same item, so one pass reads the whole
//! declaration. Nothing is registered twice and nothing is written twice.
//!
//! What comes out is the App a `main` serves and, as a side effect of
//! compiling, the fragment of the manifest that describes these ops. The op is
//! not given a name here: [zip.ID] settles that, once, for every language, so a
//! Rust op and a Go op at the same address carry the same token.
use proc_macro2::TokenStream;
use quote::quote;
use syn::{FnArg, ImplItem, ItemImpl, ReturnType, Type};
use crate::{doc, frag};
const VERBS: [&str; 5] = ["get", "post", "put", "patch", "delete"];
pub fn expand(args: TokenStream, mut block: ItemImpl) -> Result<TokenStream, syn::Error> {
let about = About::parse(args)?;
let holder = match &*block.self_ty {
Type::Path(p) => p
.path
.segments
.last()
.map(|s| s.ident.clone())
.ok_or_else(|| syn::Error::new_spanned(&block.self_ty, "zip::ops: expected a type"))?,
other => return Err(syn::Error::new_spanned(other, "zip::ops: expected a type")),
};
let mut ops = Vec::new();
for item in &mut block.items {
let ImplItem::Fn(f) = item else { continue };
let Some((verb, path)) = route(&f.attrs)? else {
continue;
};
ops.push(Op::read(verb, path, f)?);
f.attrs = doc::keep(&f.attrs, &VERBS);
}
if ops.is_empty() {
return Err(syn::Error::new_spanned(
&block.self_ty,
"zip::ops: no method carries a route; an impl block with no op declares nothing",
));
}
frag::write("ops", &holder.to_string(), &fragment(&about, &ops));
let app = about.app;
let title = about.title;
let version = about.version;
let describe = about.describe;
let registrations = ops.iter().map(Op::register);
let expanded = quote! {
#block
impl #holder {
/// Ops is this service's typed operations: the REST routes, the ZAP
/// methods, and the document that describes them, from the one
/// registration each op already carries.
pub fn ops(self) -> ::zip::App {
let holder = ::std::sync::Arc::new(self);
let mut app = ::zip::App::new(#app, #title, #version, #describe);
#(#registrations)*
app
}
}
};
Ok(expanded)
}
/// About is what the app says about itself: the facts a document's info block
/// carries and no handler knows.
struct About {
app: String,
title: String,
version: String,
describe: String,
}
impl About {
fn parse(args: TokenStream) -> Result<Self, syn::Error> {
let mut out = About {
app: String::new(),
title: String::new(),
version: String::new(),
describe: String::new(),
};
if args.is_empty() {
return Ok(out);
}
let items = syn::parse::Parser::parse2(
syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated,
args,
)?;
for item in items {
let syn::Meta::NameValue(nv) = &item else {
return Err(syn::Error::new_spanned(
&item,
"zip::ops: known keys are app, title, version, description",
));
};
let syn::Expr::Lit(l) = &nv.value else {
return Err(syn::Error::new_spanned(
&nv.value,
"zip::ops: expected a string",
));
};
let syn::Lit::Str(s) = &l.lit else {
return Err(syn::Error::new_spanned(
&nv.value,
"zip::ops: expected a string",
));
};
let v = s.value();
if nv.path.is_ident("app") {
out.app = v;
} else if nv.path.is_ident("title") {
out.title = v;
} else if nv.path.is_ident("version") {
out.version = v;
} else if nv.path.is_ident("description") {
out.describe = v;
} else {
return Err(syn::Error::new_spanned(
&nv.path,
"zip::ops: known keys are app, title, version, description",
));
}
}
Ok(out)
}
}
/// Op is one declared operation.
struct Op {
verb: String,
path: String,
call: syn::Ident,
input: Option<Type>,
output: Option<Type>,
doc: doc::Doc,
}
impl Op {
fn read(verb: String, path: String, f: &syn::ImplItemFn) -> Result<Self, syn::Error> {
let mut input = None;
for arg in f.sig.inputs.iter() {
match arg {
FnArg::Receiver(_) => {}
FnArg::Typed(t) => {
if input.is_some() {
return Err(syn::Error::new_spanned(
arg,
"zip::ops: an op takes one input; a request is one value",
));
}
input = Some(strip(&t.ty));
}
}
}
let output = match &f.sig.output {
ReturnType::Default => None,
ReturnType::Type(_, t) => answer(t),
};
Ok(Op {
verb,
path,
call: f.sig.ident.clone(),
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.
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 describe = match &self.input {
Some(i) => quote!(::std::option::Option::Some(<#i as ::zip::Wire>::describe())),
None => quote!(::std::option::Option::None),
};
quote! {
app.op(#verb, #path, #describe, {
let holder = ::std::sync::Arc::clone(&holder);
::std::boxed::Box::new(move |input: &::zip::Input| { #run })
});
}
}
fn json(&self) -> String {
let mut o = frag::Object::new();
o.text("method", &self.verb.to_uppercase())
.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.output {
o.text("out", &name_of(t));
}
if !self.doc.example.is_empty() {
o.raw("example", &self.doc.example);
}
if !self.doc.response.is_empty() {
o.raw("response", &self.doc.response);
}
o.finish()
}
}
fn fragment(about: &About, ops: &[Op]) -> String {
let items: Vec<String> = ops.iter().map(Op::json).collect();
let mut o = frag::Object::new();
o.number("manifest", 1)
.text("app", &about.app)
.text("title", &about.title)
.text("version", &about.version)
.text("description", &about.describe)
.raw("ops", &frag::list(&items));
o.finish()
}
/// route reads `#[get("/path")]` and its siblings off a method.
fn route(attrs: &[syn::Attribute]) -> Result<Option<(String, String)>, syn::Error> {
for a in attrs {
for verb in VERBS {
if !a.path().is_ident(verb) {
continue;
}
let lit: syn::LitStr = a.parse_args().map_err(|_| {
syn::Error::new_spanned(
a,
"zip::ops: a route is a string literal — an op with a computed path has no identity to document",
)
})?;
return Ok(Some((verb.to_string(), lit.value())));
}
}
Ok(None)
}
/// strip removes the reference an argument is passed by: what a handler reads
/// is the value, and how it borrows it is Rust's business, not the wire's.
fn strip(t: &Type) -> Type {
match t {
Type::Reference(r) => strip(&r.elem),
Type::Paren(p) => strip(&p.elem),
Type::Group(g) => strip(&g.elem),
other => other.clone(),
}
}
/// answer is the type inside `Result<T, _>`, or None for an op that answers
/// nothing.
fn answer(t: &Type) -> Option<Type> {
let Type::Path(p) = t else { return None };
let last = p.path.segments.last()?;
if last.ident != "Result" {
return None;
}
let syn::PathArguments::AngleBracketed(b) = &last.arguments else {
return None;
};
for g in &b.args {
if let syn::GenericArgument::Type(inner) = g {
if is_unit(inner) {
return None;
}
return Some(strip(inner));
}
}
None
}
fn is_unit(t: &Type) -> bool {
matches!(t, Type::Tuple(t) if t.elems.is_empty())
}
/// name_of is the type's own name — all a macro can see of it, and all the
/// manifest needs, because the type describes itself where it is declared.
fn name_of(t: &Type) -> String {
match t {
Type::Path(p) => p
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
_ => String::new(),
}
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! A Rust type, said neutrally.
//!
//! The macro sees the type as it is WRITTEN — `Vec<Peer>`, `Option<String>`,
//! `HashMap<String, Lp>` — and not what it resolves to, because a macro is
//! syntax and nothing more. That is enough: the shape of a container is in its
//! spelling, and a name that is not a container is a reference to a type that
//! describes itself.
//!
//! It is also the limit the study named. A field whose type comes from another
//! crate is a NAME here and nothing else; what that name means arrives from the
//! type's own description, which travels with the type.
use syn::{GenericArgument, PathArguments, Type};
/// Ref is a field's type as a manifest TypeRef.
pub struct Ref {
/// json is the rendered TypeRef object.
pub json: String,
/// spell is the type as the source writes it, for the ledger.
pub spell: String,
}
/// of reads one type.
pub fn of(t: &Type) -> Ref {
Ref {
json: body(t, false),
spell: crate::doc::spell(t),
}
}
fn body(t: &Type, opt: bool) -> String {
match t {
Type::Reference(r) => body(&r.elem, opt),
Type::Paren(p) => body(&p.elem, opt),
Type::Group(g) => body(&g.elem, opt),
Type::Slice(s) => list(&s.elem, 0, opt),
Type::Array(a) => {
let n = len(&a.len);
if prim(&a.elem) == Some("u8") {
return wrap(&format!("\"prim\":\"bytes_fixed[{n}]\""), opt);
}
list(&a.elem, n, opt)
}
Type::Path(p) => {
let last = match p.path.segments.last() {
Some(s) => s,
None => return wrap("\"prim\":\"any\"", opt),
};
let name = last.ident.to_string();
let args = generics(&last.arguments);
match (name.as_str(), args.len()) {
("Option", 1) => body(args[0], true),
("Box", 1) | ("Arc", 1) | ("Rc", 1) | ("Cow", 1) => body(args[0], opt),
("Vec", 1) | ("VecDeque", 1) => {
if prim(args[0]) == Some("u8") {
return wrap("\"prim\":\"bytes\"", opt);
}
list(args[0], 0, opt)
}
("HashMap", 2) | ("BTreeMap", 2) => {
wrap(&format!("\"map\":{}", body(args[1], false)), opt)
}
_ => match prim(t) {
Some(p) => wrap(&format!("\"prim\":\"{p}\""), opt),
None => wrap(&format!("\"ref\":{}", crate::frag::quote(&name)), opt),
},
}
}
_ => wrap("\"prim\":\"any\"", opt),
}
}
fn list(elem: &Type, n: usize, opt: bool) -> String {
let mut fields = format!("\"list\":{}", body(elem, false));
if n > 0 {
fields.push_str(&format!(",\"len\":{n}"));
}
wrap(&fields, opt)
}
fn wrap(fields: &str, opt: bool) -> String {
if opt {
format!("{{{fields},\"opt\":true}}")
} else {
format!("{{{fields}}}")
}
}
/// prim is the neutral spelling of a built-in, or None for a named type.
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(),
_ => return None,
};
Some(match name.as_str() {
"bool" => "bool",
"i8" => "i8",
"i16" => "i16",
"i32" => "i32",
"i64" | "isize" => "i64",
"u8" => "u8",
"u16" => "u16",
"u32" => "u32",
"u64" | "usize" => "u64",
"f32" => "f32",
"f64" => "f64",
"String" | "str" => "string",
_ => return None,
})
}
fn generics(a: &PathArguments) -> Vec<&Type> {
match a {
PathArguments::AngleBracketed(b) => b
.args
.iter()
.filter_map(|g| match g {
GenericArgument::Type(t) => Some(t),
_ => None,
})
.collect(),
_ => vec![],
}
}
fn len(e: &syn::Expr) -> usize {
if let syn::Expr::Lit(l) = e {
if let syn::Lit::Int(i) = &l.lit {
return i.base10_parse().unwrap_or(0);
}
}
0
}
+449
View File
@@ -0,0 +1,449 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! `#[derive(Wire)]` — a type that says what it is.
//!
//! A proc-macro sees only the item it is written on, so the op macro learns a
//! parameter's NAME and nothing else. The description has to travel with the
//! type, and this is how: the derive emits the type's own field list, wire
//! 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.
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{Data, DeriveInput, Fields, Type};
use crate::attr::Attrs;
use crate::{doc, frag, ty};
pub fn derive(input: DeriveInput) -> Result<TokenStream, syn::Error> {
let name = input.ident.clone();
let id = name.to_string();
let own = Attrs::read(&input.attrs)?;
let prose = doc::read(&input.attrs);
let Data::Struct(s) = &input.data else {
return Err(syn::Error::new_spanned(
&input.ident,
"zip::Wire describes a struct: a value with fields, or a newtype that is one value",
));
};
match &s.fields {
Fields::Named(named) => {
let fields: Vec<Field> = named
.named
.iter()
.map(Field::read)
.collect::<Result<_, _>>()?;
frag::write("types", &id, &record(&id, &prose.text, &fields));
Ok(shape(&name, &id, &fields))
}
Fields::Unnamed(un) if un.unnamed.len() == 1 => {
let inner = &un.unnamed[0].ty;
frag::write("types", &id, &value(&id, &own, inner));
Ok(newtype(&name, &id, &own, inner))
}
_ => Err(syn::Error::new_spanned(
&input.ident,
"zip::Wire describes a struct with named fields, or a newtype around one value",
)),
}
}
/// Field is one described field.
struct Field {
name: syn::Ident,
json: String,
url: String,
header: String,
required: bool,
doc: String,
ty: Type,
reference: ty::Ref,
}
impl Field {
fn read(f: &syn::Field) -> Result<Self, syn::Error> {
let a = Attrs::read(&f.attrs)?;
let name = f.ident.clone().expect("named field");
let json = if a.skip {
"-".to_string()
} else {
a.json.clone().unwrap_or_else(|| name.to_string())
};
Ok(Field {
json,
url: a.url.clone().unwrap_or_default(),
header: a.header.clone().unwrap_or_default(),
required: a.required,
doc: doc::read(&f.attrs).text,
reference: ty::of(&f.ty),
ty: f.ty.clone(),
name,
})
}
}
/// record is the fragment for a struct.
fn record(id: &str, prose: &str, fields: &[Field]) -> String {
let items: Vec<String> = fields
.iter()
.map(|f| {
let mut o = frag::Object::new();
o.text("name", &f.name.to_string())
.text("json", &f.json)
.text("url", &f.url)
.text("header", &f.header)
.flag("required", f.required)
.text("doc", &f.doc)
.raw("type", &f.reference.json)
.text("spell", &f.reference.spell);
o.finish()
})
.collect();
let mut o = frag::Object::new();
o.text("id", id)
.text("name", id)
.text("kind", "struct")
.text("spell", id)
.text("doc", prose)
.raw("fields", &frag::list(&items));
o.finish()
}
/// value is the fragment for a newtype: one value, whose wire form is its own
/// business. It is the escape hatch the study named — a type whose JSON is not
/// what it is made of has to say so, and this is where it says it.
fn value(id: &str, a: &Attrs, inner: &Type) -> String {
let mut o = frag::Object::new();
o.text("id", id)
.text("name", id)
.text("kind", "scalar")
.text("spell", id)
.text("repr", ty::prim(inner).unwrap_or("any"))
.flag("text", a.text);
if let Some(j) = a.schema() {
o.raw("json", j);
} else if a.text {
o.raw("json", "{\"type\":\"string\"}");
}
o.finish()
}
/// shape is the impl for a struct: the description a binder reads, and the JSON
/// the wire carries.
fn shape(name: &syn::Ident, id: &str, fields: &[Field]) -> TokenStream {
let descs = fields.iter().map(|f| {
let n = f.name.to_string();
let j = &f.json;
let u = &f.url;
let h = &f.header;
let req = f.required;
let scalar = scalar_of(&f.ty);
quote! {
::zip::FieldDesc { name: #n, json: #j, url: #u, header: #h, required: #req, scalar: #scalar }
}
});
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 }
});
quote! {
impl ::zip::Wire for #name {
fn describe() -> &'static ::zip::TypeDesc {
static DESC: ::zip::TypeDesc = ::zip::TypeDesc {
id: #id,
text: false,
fields: &[#(#descs),*],
};
&DESC
}
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, a: &Attrs, inner: &Type) -> TokenStream {
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
}
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, ": "))),
}
}
}
};
}
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
}
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))
}
}
}
}
/// scalar_of is what a URL can put in this field, which is what lets a query
/// value become the JSON the one decoder reads.
fn scalar_of(t: &Type) -> TokenStream {
match shape_of(t) {
Shape::Prim(p) => match p {
"string" => quote!(::zip::Scalar::Text),
"bool" => quote!(::zip::Scalar::Bool),
"f32" | "f64" => quote!(::zip::Scalar::Number),
"bytes" => quote!(::zip::Scalar::Text),
_ => quote!(::zip::Scalar::Number),
},
Shape::Opt(inner) => scalar_of(&inner),
Shape::List(inner) => {
let e = scalar_of(&inner);
quote!(::zip::Scalar::List(&#e))
}
Shape::Named => quote!(::zip::Scalar::Named),
_ => quote!(::zip::Scalar::Body),
}
}
/// Shape is the structure of a written type, which is all a macro can see.
enum Shape {
Prim(&'static str),
Opt(Type),
List(Type),
Map(Type),
Named,
Other,
}
fn shape_of(t: &Type) -> Shape {
match t {
Type::Reference(r) => shape_of(&r.elem),
Type::Paren(p) => shape_of(&p.elem),
Type::Group(g) => shape_of(&g.elem),
Type::Path(p) => {
let Some(last) = p.path.segments.last() else {
return Shape::Other;
};
let name = last.ident.to_string();
let args: Vec<Type> = match &last.arguments {
syn::PathArguments::AngleBracketed(b) => b
.args
.iter()
.filter_map(|g| match g {
syn::GenericArgument::Type(t) => Some(t.clone()),
_ => None,
})
.collect(),
_ => vec![],
};
match (name.as_str(), args.len()) {
("Option", 1) => Shape::Opt(args[0].clone()),
("Vec", 1) | ("VecDeque", 1) => {
if ty::prim(&args[0]) == Some("u8") {
Shape::Prim("bytes")
} else {
Shape::List(args[0].clone())
}
}
("HashMap", 2) | ("BTreeMap", 2) => Shape::Map(args[1].clone()),
_ => match ty::prim(t) {
Some(p) => Shape::Prim(p),
None => Shape::Named,
},
}
}
_ => 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",
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "zip"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "A ZAP-native web framework: one registration, every projection."
[dependencies]
zip-macros = { path = "../zip-macros" }
+48
View File
@@ -0,0 +1,48 @@
// 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."
),
}
}
+386
View File
@@ -0,0 +1,386 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// 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.
//!
//! 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
//! spellings of one.
use std::collections::HashMap;
use std::sync::Arc;
use crate::{Error, FieldDesc, Json, Scalar, TypeDesc, Wire};
/// 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>,
}
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>;
/// 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,
segments: Vec<Segment>,
}
enum Segment {
Literal(String),
Param(String),
Rest(String),
}
/// App is a service: its ops, and what it says about itself.
pub struct App {
pub name: &'static str,
pub title: &'static str,
pub version: &'static str,
pub description: &'static str,
ops: Vec<Op>,
files: HashMap<String, (&'static str, &'static str)>,
}
impl App {
pub fn new(
name: &'static str,
title: &'static str,
version: &'static str,
description: &'static str,
) -> Self {
App {
name,
title,
version,
description,
ops: Vec::new(),
files: HashMap::new(),
}
}
/// 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 {
self.ops.push(Op {
method,
path,
input,
call,
segments: split(path),
});
self
}
/// serve publishes a file at an address: the projections zipc wrote, so a
/// caller can read the document from the service that implements it.
pub fn serve(&mut self, path: &str, kind: &'static str, body: &'static str) -> &mut Self {
self.files.insert(path.to_string(), (kind, body));
self
}
pub fn ops_len(&self) -> usize {
self.ops.len()
}
/// routes is every registered address, for a startup line.
pub fn routes(&self) -> Vec<(&'static str, &'static str)> {
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" {
if let Some((kind, text)) = self.files.get(path) {
return Answer {
status: 200,
kind,
body: text.as_bytes().to_vec(),
};
}
}
let mut allowed = false;
for op in &self.ops {
let Some(params) = op.bind_path(path) else {
continue;
};
if op.method != method {
allowed = true;
continue;
}
let input = Input {
method: method.to_string(),
path: 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(),
},
};
}
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 is what a door writes back.
pub struct Answer {
pub status: u16,
pub kind: &'static str,
pub body: Vec<u8>,
}
impl Op {
/// bind_path matches this op's pattern against an address, answering the
/// segments it captured.
fn bind_path(&self, path: &str) -> Option<Vec<(String, String)>> {
let mut got = Vec::new();
let mut parts = path.split('/').filter(|s| !s.is_empty());
let mut want = self.segments.iter();
loop {
match want.next() {
None => {
return if parts.next().is_none() {
Some(got)
} else {
None
}
}
Some(Segment::Rest(name)) => {
let rest: Vec<&str> = parts.collect();
got.push((name.clone(), rest.join("/")));
return Some(got);
}
Some(Segment::Literal(lit)) => {
if parts.next()? != lit {
return None;
}
}
Some(Segment::Param(name)) => {
got.push((name.clone(), parts.next()?.to_string()));
}
}
}
}
}
fn split(path: &str) -> Vec<Segment> {
path.split('/')
.filter(|s| !s.is_empty())
.map(|s| {
if let Some(name) = s.strip_prefix(':') {
Segment::Param(name.to_string())
} else if s == "*" || s == "+" {
Segment::Rest("*1".to_string())
} else {
Segment::Literal(s.to_string())
}
})
.collect()
}
fn split_target(target: &str) -> (&str, Vec<(String, String)>) {
let (path, rest) = match target.split_once('?') {
Some((p, q)) => (p, q),
None => (target, ""),
};
let mut query = Vec::new();
for pair in rest.split('&').filter(|s| !s.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
query.push((unescape(k), unescape(v)));
}
(path, query)
}
/// unescape reads percent-encoding out of one URL value.
fn unescape(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < b.len() => {
match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
{
Ok(v) => {
out.push(v);
i += 3;
}
Err(_) => {
out.push(b'%');
i += 1;
}
}
}
c => {
out.push(c);
i += 1;
}
}
}
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));
}
}
}
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)));
}
}
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()),
}
}
/// Shared is an app several doors serve at once.
pub type Shared = Arc<App>;
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! What a type says about itself, in the running service.
//!
//! The same description the manifest carries, minus everything only a projector
//! needs. A binder asks two questions of a field — what is it called on the
//! wire, and what can a URL put in it — and this answers exactly those, so
//! there is no second table of names for a request to be bound against.
//!
//! It is a const. [`Wire::describe`] hands back a `&'static`, so a type's
//! description costs nothing at run time and cannot be different on the second
//! call than on the first.
/// TypeDesc is one described type.
pub struct TypeDesc {
/// id is the name every projection refers to this type by.
pub id: &'static str,
/// text says the value is carried as one word, so a URL can hold it.
pub text: bool,
pub fields: &'static [FieldDesc],
}
/// FieldDesc is one field of a described type.
pub struct FieldDesc {
/// name is the field as the source declares it; json is what the wire calls
/// it, "-" for a field the body does not carry.
pub name: &'static str,
pub json: &'static str,
/// url is the name a URL carries it under, "" when that is the wire name
/// and "-" when the URL does not carry it at all.
pub url: &'static str,
/// header is the request header it reads.
pub header: &'static str,
pub required: bool,
/// scalar is what a URL can put here, which is what turns one query value
/// into the JSON the one decoder reads.
pub scalar: Scalar,
}
impl FieldDesc {
/// url_name is the name a URL carries this field under.
pub fn url_name(&self) -> &'static str {
if self.url.is_empty() {
self.json
} else {
self.url
}
}
}
/// Scalar is what a URL can carry into a field.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Scalar {
Text,
Number,
Bool,
/// List is a repeated value, spelled comma-separated in one URL value.
List(&'static Scalar),
/// Named is a type that describes itself; a URL carries it when it is
/// carried as text, and otherwise it rides the body.
Named,
/// Body is a value a URL has no spelling for.
Body,
}
/// Wire is a type that says what it is and carries itself over JSON.
///
/// 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.
pub trait Wire: Sized {
fn describe() -> &'static TypeDesc;
fn write_json(&self, out: &mut String);
fn read_json(v: &crate::Json) -> Result<Self, crate::Error>;
}
+64
View File
@@ -0,0 +1,64 @@
// 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 when it cannot.
//!
//! A status and a sentence. The status is the contract — a client branches on
//! it — and the sentence is for the person reading the log, so it says what
//! happened and never what it was holding when it happened.
use std::fmt;
/// Error is a refusal with a status on it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Error {
pub status: u16,
/// detail says what happened to THIS request. The title is the status's own
/// phrase and says what kind of thing happened, which is what a client
/// branches on; the detail is for the person reading it.
pub detail: String,
}
impl Error {
/// new is a refusal at a status of your choosing.
pub fn new(status: u16, detail: impl Into<String>) -> Self {
Error {
status,
detail: detail.into(),
}
}
/// bad is a request this service will not read: 400.
pub fn bad(detail: impl Into<String>) -> Self {
Error::new(400, detail)
}
/// missing is an address that names nothing: 404.
pub fn missing(detail: impl Into<String>) -> Self {
Error::new(404, detail)
}
/// broke is this service's own fault: 500.
pub fn broke(detail: impl Into<String>) -> Self {
Error::new(500, detail)
}
/// problem is the body a refusal travels in — RFC 9457, the same shape the
/// Go side answers with, so one client reads both.
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
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.status, self.detail)
}
}
impl std::error::Error for Error {}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! The HTTP door.
//!
//! 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.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use crate::{App, Error};
/// 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 };
let app = Arc::clone(&app);
std::thread::spawn(move || {
let _ = serve(&app, conn);
});
}
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()
);
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()
}
/// 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",
}
}
/// refuse is what a door writes when it cannot even read the request.
pub fn refuse(e: &Error) -> Vec<u8> {
e.problem().into_bytes()
}
+371
View File
@@ -0,0 +1,371 @@
// 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))
}
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//! zip — a ZAP-native web framework, in Rust.
//!
//! A handler is a method with a doc comment and a route on it. Registering it
//! yields the REST route, the ZAP method, the OpenAPI document, the MCP tool,
//! the CLI command and the ZAP IDL, from that one registration and the sentence
//! 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.
//! ///
//! /// Example: {"chain": "X"}
//! /// Response: {"isBootstrapped": true}
//! #[get("/chain/bootstrapped")]
//! fn bootstrapped(&self, arg: &BootstrappedArgs) -> Result<Bootstrapped, zip::Error> {
//! Ok(Bootstrapped { is_bootstrapped: true })
//! }
//! }
//! ```
//!
//! # 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.
//!
//! 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
//! written in Rust, in Go or in C++, because one piece of code derived all
//! three. What is native is DECLARING an op and RUNNING it, and both of those
//! are Rust here, all the way down. No Go runs in a service built with this.
mod app;
mod desc;
mod error;
pub mod json;
pub mod http;
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, Shared};
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};
/// listen serves an app on every address given, and does not return.
///
/// 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
/// ```
pub fn listen(app: App, http: Option<&str>, zap: Option<&str>) -> std::io::Result<()> {
let app = std::sync::Arc::new(app);
let mut doors = Vec::new();
if let Some(addr) = zap {
let app = std::sync::Arc::clone(&app);
let addr = addr.to_string();
doors.push(std::thread::spawn(move || zaphttp::listen(app, &addr)));
}
if let Some(addr) = http {
let app = std::sync::Arc::clone(&app);
let addr = addr.to_string();
doors.push(std::thread::spawn(move || http::listen(app, &addr)));
}
for door in doors {
match door.join() {
Ok(r) => r?,
Err(_) => return Err(std::io::Error::other("a door stopped")),
}
}
Ok(())
}
+167
View File
@@ -0,0 +1,167 @@
// 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 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.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use crate::wire::{self, Request};
use crate::{zap, App};
/// 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;
/// 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.
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 };
let app = Arc::clone(&app);
std::thread::spawn(move || {
let _ = serve(&app, conn);
});
}
Ok(())
}
fn serve(app: &App, mut conn: TcpStream) -> std::io::Result<()> {
let mut out = conn.try_clone()?;
loop {
let Some(frame) = read_frame(&mut conn)? else {
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())
}
Err(_) => crate::Answer {
status: 400,
kind: "application/problem+json",
body: crate::http::refuse(&crate::Error::bad("malformed request frame")),
},
};
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()?;
}
}
/// read_frame takes one length-prefixed frame off the connection.
fn read_frame(conn: &mut TcpStream) -> std::io::Result<Option<Vec<u8>>> {
let mut head = [0u8; 4];
match conn.read_exact(&mut head) {
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;
if n == 0 || n > MAX_FRAME {
return Ok(None);
}
let mut frame = vec![0u8; n];
conn.read_exact(&mut frame)?;
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)
}
+29
View File
@@ -0,0 +1,29 @@
# 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
}