mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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/
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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), "#") }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
Generated
+61
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "info"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
zip = { path = "../../zip" }
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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(" ;", ";")
|
||||
}
|
||||
@@ -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("e(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, "e(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(","))
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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."
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user