A README to build a service from, and examples/local-service

The README gave no version to install and no service to run, never
mentioned zipdoc, Described, ErrorHandler, the call plane's field-order
rule, zipgen, CommandsFromSpec, Authorize or the MCP door's limits, and
named APIs that do not exist (app.Add, app.Mount, a zip.Load that takes a
prefix first). It now takes one service through REST, MCP, OpenAPI and the
CLI with output from real runs, then covers each projection, the error
format and the release that introduced it, and the security model.

examples/local-service is that service: one SQLite-backed operation, doc
comments run through zipdoc, a .mcp.json for Claude Code, and a test that
checks every operation against every projection. It is a module of its own
so the SQLite driver stays out of zip's graph. CI runs zipdoc -check and
its tests.

LLM.md is now a map of the code, the test commands, the release steps and
the invariants that hold today.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-11 10:36:14 -07:00
co-authored by Hanzo Dev
parent 4bba90f0d7
commit c53d10fe4b
9 changed files with 982 additions and 1633 deletions
+53 -1531
View File
File diff suppressed because it is too large Load Diff
+407 -102
View File
@@ -1,126 +1,431 @@
# zip
> **Docs:** [zip](https://zap-proto.dev/docs/zip) · part of the [ZAP Protocol](https://zap-proto.io)
The ZAP-native Go web framework. Built on [**Fiber v3**](https://github.com/zap-proto/fiber) / fasthttp. A route is declared **once, as a typed operation**, and zip projects it into REST, OpenAPI, MCP, a CLI and a by-name call plane — with **ZAP as the primary transport**, HTTP a secondary view of the same routes.
[**zap-proto.io**](https://zap-proto.io) · [Docs](https://zap-proto.dev/docs/zip) · [fiber](https://github.com/zap-proto/fiber) · [Spec](https://github.com/zap-proto/spec)
**ONE framework. ONE `Listen` verb. Operations declared once, served over every transport, projected into every interface.**
```go
package main
import (
"context"
"log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// The In and Out types ARE the contract. Nothing below is written twice.
type GetUserIn struct {
ID string `json:"id"` // binds from the :id path segment
}
type User struct {
ID string `json:"id"`
Org string `json:"org"`
}
// GetUser returns one user, scoped to the caller's org.
func getUser(ctx context.Context, in *GetUserIn) (*User, error) {
return &User{ID: in.ID, Org: zip.CallerOf(ctx).Org}, nil // gateway-minted identity
}
func main() {
app := zip.New(zip.Config{})
app.Use(middleware.Recover(), middleware.RequestID())
// ONE typed op → a REST route, an OpenAPI operation, an MCP tool,
// a `<service> <operation>` command, and a target for zip.Call.
zip.Get(app, "/v1/users/:id", getUser)
log.Fatal(app.Listen(":9653", "http://:8080")) // ZAP primary + HTTP extra, one verb
}
```
zip is a Go web framework on fasthttp. You write an operation once, as a function with a typed input and output, and zip serves it as a REST route, an OpenAPI operation, an MCP tool, a CLI command and a call other services make over ZAP.
## Install
```bash
go get github.com/zap-proto/zip
```sh
go get github.com/zap-proto/zip@latest
go list -m github.com/zap-proto/zip
```
Module path `github.com/zap-proto/zip`. Requires Go 1.26+.
```
github.com/zap-proto/zip v1.36.48
```
## Features
zip needs Go 1.26.8 or newer. `go list -m -versions github.com/zap-proto/zip` lists every release. A copy taken from an old module cache may be many releases behind; see [Upgrading](#upgrading).
- **Typed ops are the API** — `zip.Get/Post/Put/Patch/Delete[In, Out](on, path, fn)` is how a route is declared. It registers ONE operation; every interface below is derived from it, and `op.invoke` (decode → validate → authorize → run) is the one handler core all of them share. The In/Out types are the contract, so the document, the tool, the command and the client cannot drift from the code — there is nowhere for them to drift *to*.
## A service in one file
`on` is the App, any `Group` of it, or `app.With(mw…)` — a group's prefix is part of the op and `With`'s middleware wraps its handler, so structuring the router costs nothing in schema:
[`examples/local-service`](examples/local-service) stores notes in SQLite and has one operation. This is its `main.go`:
```go
v1 := app.Group("/v1")
zip.Get(v1, "/users/:id", getUser) // one op at /v1/users/:id
zip.Post(app.With(RateLimit), "/v1/keys", mint) // gated, and still one op
```
- **Untyped routes are the escape hatch, and they cost every projection** — `app.Get(path, func(c *zip.Ctx) error)` registers a route and **no operation**. The endpoint is then in no OpenAPI document, is no MCP tool, has no command, and no service can reach it with `zip.Call`; it is reachable only by someone who already knows the URL. That is the right trade when the response is something a schema cannot describe — an SSE stream, a protocol upgrade, a proxied byte range, a non-JSON body — and the wrong one for everything else. If you can name what goes in and what comes out, declare it.
- **Transport is a value, not a method** — one verb, `app.Listen(addrs...)`, and the address scheme selects the transport (mirrors `net.Listen`):
```go
// Command local-service is a zip service with one operation, backed by SQLite.
//
// The operation is declared once, in New, and every interface below is derived
// from that declaration:
//
// go run . serve # REST, MCP and OpenAPI on 127.0.0.1:8080
// go run . notes create --text "buy milk" # the same operation as a command
// go run . openapi openapi.json # the OpenAPI document, written to a file
//
// Notes are stored in notes.db in the working directory.
package main
```go
app.Listen(":9653") // ZAP (bare addr = the primary)
app.Listen(":9653", "http://:8080") // ZAP + HTTP in one call
app.Listen("http://:8080") // HTTP only
app.Listen("/run/hanzo/app.sock") // ZAP on a unix socket
app.Listen("quic://:443") // any RegisterTransport'd protocol
```
//go:generate go tool zipdoc
ZAP (TLS 1.3 + post-quantum) is the default; HTTP is built in; `zip.RegisterTransport(scheme, zip.Transport{Serve, Dial})` adds any future protocol with zero change to `Listen` **or** `Mount`. The scheme names the *protocol* and the address names where it is spoken — a path is a unix socket, a `host:port` is TCP — so one wire never needs two schemes.
- **Composition is one verb** — `Service` is `func(*App) error`: a unit that attaches its own routes, middleware and shutdown hooks. A constructor taking dependencies and returning one is the same thing curried, so a composition root only ever sees `Service`.
import (
"context"
"database/sql"
"fmt"
"os"
```go
app.Add(billing.New(deps), search.New(deps))
```
- **Plugins — a service builds as its own binary and loads at run time** — because a plugin is also just a `Service`, where it runs is a deployment decision rather than a code change:
"github.com/hanzoai/sqlite"
"github.com/zap-proto/zip"
)
```go
//go:embed bin/billing
var billingBin []byte
// Note is one stored note.
type Note struct {
// ID is the row id SQLite assigned.
ID int64 `json:"id"`
// Text is the note as written.
Text string `json:"text"`
}
app.Add(
billing.New(deps), // linked in
zip.Load("/v1/billing", zip.Plugin{Name: "billing", Bin: billingBin}), // its own binary
zip.Load("/v1/ml", zip.Plugin{Name: "ml", Addr: mlAddr}), // already running
)
```
// AddIn is a note to store.
type AddIn struct {
// Text is the note body.
Text string `json:"text" validate:"required"`
}
A plugin is an ordinary zip app — no SDK, no schema — started on its own unix socket and reached over ZAP. The host links zip and a transport, never a plugin's dependency graph, so its link time doesn't grow when a plugin does, plugins build in parallel, and `go:embed` keeps the deployment a single artifact. `app.Reload(name, bin)` swaps a running plugin for a new build without dropping a request: the replacement must be listening before any traffic moves to it, so a bad build can't take the route down, and routes register once and resolve their target per request, so repeated reloads stay flat in memory. `app.Mount(prefix, addr)` is the same delegation without the process management.
- **One registry, five projections** — one `zip.Get[In, Out](app, path, fn)` becomes a REST route, an OpenAPI 3.1 doc (`/.well-known/openapi.json`, Swagger UI at `/docs`), a Model Context Protocol tool at `/mcp` (JSON-RPC 2.0), a command line (`app.CLI()`, or `zip.CommandsFromSpec` for a client that links none of the service), **and** a by-name call plane other services reach with `zip.Call`. Same schema, same handler, one operation id addressing all five. Because each is an ordinary route, they ride every transport `Listen` was given — ZAP-native MCP is automatic. On by default; `Config.MCP.Disabled` to suppress.
// Store keeps notes in one SQLite file.
type Store struct {
db *sql.DB
}
```
┌── REST route method + path
├── OpenAPI 3.1 operationId
zip.Get[In,Out] ──op──┼── MCP tool operationId
├── CLI command operationId
└── zip.Call plane operationId
```
- **Services call services without linking** — `zip.DialApp("flags")` opens a ZAP connection over that app's canonical unix socket (`$ZIP_RUNTIME_DIR/flags.sock`) and `zip.Call[In, Out](ctx, c, "flags_bool", &in)` invokes one op, typed both ways, with the callee's `*HTTPError` intact on failure. No import of the callee's package, no hand-written client, no generated one to drift. Identity is the gateway's headers forwarded (`c.Forward()`) plus the kernel's `SO_PEERCRED` view of the calling process (`zip.PeerOf(ctx)`) — nothing the caller can forge.
- **Precedence is a property of the pattern** — routing comes from the [`zap-proto/fiber`](https://github.com/zap-proto/fiber) fork: the most specific pattern wins regardless of registration order (`static ≻ :param ≻ *`), and ambiguous equal-specificity overlaps panic at startup instead of silently shadowing.
- **Identity built-in** — `c.Org() / c.User() / c.UserEmail() / c.IsAdmin()` read JWT-validated `X-*` headers set by the gateway; handlers never parse tokens.
- **Middleware** — `Recover`, `RequestID`, `Logger`, `Timeout`, `MaxBody`, `CORS`, `RateLimit`, `Telemetry`, `Breaker` in `zip/middleware`.
- **WebSocket & streaming** — `wsx.Upgrade(fn)` over fasthttp/websocket; `c.SendStreamWriter` for SSE / chunked responses.
- **Extension routes** — `app.Module("POST /v1/eval", "wasm", "./policy")` mounts a sandboxed extension (wasm / goja / pyvm / starlark / v8go / native) as a route.
- **Embedded JS/TS runtime** — run a `(req, res)`-shaped JS/TS handler in-process via goja (pure Go, no CGO); esbuild transpiles TS ahead of it, for incremental migration to native Go.
- **Drop-in migration** — `app.All("/legacy/*", zip.AdaptNetHTTP(h))` fronts any `http.Handler` as one wildcard route; it obeys the same precedence, so a native route added later still wins.
- **Stdlib JSON only** — every JSON path goes through one internal helper backed by `encoding/json/v2` when built with `GOEXPERIMENT=jsonv2` (Go 1.25+), else `encoding/json`. No third-party JSON library.
// Open opens the SQLite file at path, creating it and its table if needed.
func (s *Store) Open(path string) error {
db, err := sqlite.OpenDB(path, nil)
if err != nil {
return err
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT NOT NULL)`); err != nil {
db.Close()
return err
}
s.db = db
return nil
}
## Documentation
// Add stores a note and returns it with its id.
//
// Example: {"text": "buy milk"}
// Response: {"id": 1, "text": "buy milk"}
func (s *Store) Add(ctx context.Context, in *AddIn) (*Note, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO notes (text) VALUES (?)`, in.Text)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return &Note{ID: id, Text: in.Text}, nil
}
The full guide — Ctx reference, the route-precedence contract, middleware, extension-runtime mounts, and versioning — is at **[zap-proto.dev/docs/zip](https://zap-proto.dev/docs/zip)**. Runnable examples live in [`examples/`](./examples).
// New declares the service's operations. It does not touch the database, so the
// OpenAPI document can be written by a build step that has none.
func New(s *Store) *zip.App {
app := zip.New(zip.Config{AppName: "notes"})
zip.Post(app, "/v1/notes", s.Add)
return app
}
Start with [`examples/hello`](./examples/hello) (two typed ops, four projections) and [`examples/zap-typed`](./examples/zap-typed). [`examples/sse-streaming`](./examples/sse-streaming) and [`examples/websocket`](./examples/websocket) are the two that stay untyped, and each says why at the top of the file — a stream and an upgrade have no single value for an `Out` to be. The `migrate-from-*` examples show the port as two steps: mechanical first, typed second, because stopping after the first leaves you with exactly the surface you were migrating away from.
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
s := &Store{}
app := New(s)
// `local-service openapi <file>` and `local-service declare <file>` write a
// projection and stop here, before the database is opened.
if done, err := app.Described(); done {
return err
}
if err := s.Open("notes.db"); err != nil {
return err
}
if len(os.Args) > 1 && os.Args[1] == "serve" {
return app.Listen("http://127.0.0.1:8080")
}
cli := app.CLI()
cli.Out = os.Stdout
return cli.Run(context.Background(), os.Args[1:])
}
```
Start it:
```sh
cd examples/local-service
go run . serve
```
From a second terminal, call the operation three ways. Over REST:
```sh
curl -s -X POST http://127.0.0.1:8080/v1/notes -H 'Content-Type: application/json' -d '{"text":"buy milk"}'
```
```json
{"id":1,"text":"buy milk"}
```
As an MCP tool:
```sh
curl -s -X POST http://127.0.0.1:8080/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"post_notes","arguments":{"text":"call the bank"}}}'
```
```json
{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"{\"id\":2,\"text\":\"call the bank\"}","type":"text"}]}}
```
As a command. It runs the handler in this process, against the same `notes.db`; zip's log lines go to stderr:
```sh
go run . notes create --text "water the plants"
```
```json
{
"id": 3,
"text": "water the plants"
}
```
One handler and one table produced ids 1, 2 and 3. The OpenAPI document needs neither a server nor the database:
```sh
go run . openapi openapi.json
```
The `/v1/notes` entry, with its examples removed:
```json
{
"post": {
"operationId": "post_notes",
"summary": "Stores a note and returns it with its id.",
"description": "Stores a note and returns it with its id.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AddIn"}}}
},
"responses": {
"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Note"}}}},
"default": {"description": "refused", "content": {"application/problem+json": {"schema": {"$ref": "#/components/schemas/problem-details"}}}}
}
}
}
```
`go generate` rebuilds `zipdoc_gen.go` from the doc comments, and `go test` checks the operation over REST, MCP, OpenAPI and the CLI. The directory is its own module, so the SQLite driver (`github.com/hanzoai/sqlite`) is not a dependency of zip. A copy outside this repository drops the `replace` line from its `go.mod`.
## Projections
Everything below starts from the one declaration `zip.Post(app, "/v1/notes", s.Add)`.
### REST
| | |
|---|---|
| Declare | `zip.Get`, `zip.Post`, `zip.Put`, `zip.Patch`, `zip.Delete`, called as `zip.Post[In, Out](on, path, fn, opts...)`. `on` is the `*zip.App`, `app.Group("/v1")` or `app.With(middleware...)`. |
| Input | The JSON body (GET, HEAD and DELETE have none), then fields tagged `header:"X-Name"`, then the query string, then path parameters. The URL wins. `validate:"required"` refuses a request without the field. |
| Options | `zip.WithOperationID`, `zip.WithSummary`, `zip.WithTags`, `zip.WithStatus(201)`, `zip.WithResponseHeader`. |
| Untyped routes | `app.Get(path, func(c *zip.Ctx) error)` registers a route with no operation, so it is in none of the projections below. Use it for streams, protocol upgrades and non-JSON bodies. |
| Tests | `app.Test(httptest.NewRequest(...))` serves a request without a socket, including `/mcp` and the OpenAPI document. |
### OpenAPI
- `GET /.well-known/openapi.json` (`zip.SpecPath`) serves OpenAPI 3.1. `app.OpenAPISpec()` returns the same document as a map.
- `app.Described()` writes a projection when the binary runs as `<binary> openapi <file>` or `<binary> declare <file>`, and reports whether it did. Call it after registering operations and before opening a database, as `run` does above.
- `zip.Config{OpenAPI: zip.OpenAPIConfig{Title, Description, Version, Disabled}}` sets the `info` block or turns the document off.
- Every operation declares a `default` response: the `problem-details` schema under `application/problem+json`, or `oauth-error` at an address declared with `zip.OAuth`. SDK generators type errors from it.
- `GET /docs` (`zip.DocsPath`) is a Swagger UI page that loads its script and stylesheet from cdn.jsdelivr.net, so it is blank without network access. The JSON document needs none.
### Doc comments
`cmd/zipdoc` copies doc comments into the OpenAPI document, the MCP tool list and the CLI help:
```sh
go get -tool github.com/zap-proto/zip/cmd/zipdoc
```
Put `//go:generate go tool zipdoc` in the package that registers the operations and run `go generate`. It writes `zipdoc_gen.go`: the handler's comment becomes the description, its `Example:` and `Response:` lines become examples, and field comments become schema descriptions. In CI, run `go tool zipdoc -check` in that package's directory. It fails when `zipdoc_gen.go` no longer matches the source.
### MCP
Each operation is a tool named by its operation id (`post_notes`), with its input schema as `inputSchema`. GET operations carry `readOnlyHint: true`. `app.MCPTools()` returns the list in-process.
```sh
curl -s -X POST http://127.0.0.1:8080/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```
```json
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{"listChanged":false}},"protocolVersion":"2025-11-25","serverInfo":{"name":"notes","version":""}}}
```
What the HTTP door does:
| | |
|---|---|
| Transport | `POST /mcp`: one JSON-RPC message per request, answered with one `application/json` message. |
| GET and DELETE | `405 Method Not Allowed` with `Allow: POST`. There is no SSE stream; a request that accepts only `text/event-stream` still gets JSON. |
| Sessions | None. No `Mcp-Session-Id`, and nothing is kept between requests. |
| Batches | Not accepted. A JSON array answers `-32700 parse error`. |
| Notifications | `202 Accepted`. |
| Methods | `initialize`, `server/discover`, `ping`, `tools/list`, `tools/call`. No resources, prompts, sampling or logging. |
| Protocol version | `initialize` answers the revision the client asked for when it is 2025-06-18, 2025-11-25 or 2026-07-28, and 2026-07-28 otherwise. |
| Tool errors | A result with `isError: true` and the error text, not a JSON-RPC error. |
| Origin | Not checked. Bind a local service to 127.0.0.1, as the example does, and serve anything else behind a gateway. |
[`examples/local-service/.mcp.json`](examples/local-service/.mcp.json) registers the running example with Claude Code. The official MCP clients connect to it, list its tools and call them: the TypeScript SDK 1.30.0, and the Python SDK 2.2.0 through `initialize` and in its default `auto` mode.
`zip.Config{MCP: zip.MCPConfig{...}}` has `Disabled`, `Path` and `Addr`, which serves the same tools over ZAP with no HTTP listener. `app.MCP` is the door itself, a `zapmcp.Handler`.
### CLI
`app.CLI()` returns a `*zip.CLI` that runs operations in this process. Set `Out` before `Run`:
```go
cli := app.CLI()
cli.Out = os.Stdout
err := cli.Run(ctx, os.Args[1:])
```
A command is `<service> <operation>`: the first path segment after the version, then a verb from the method, so `POST /v1/notes` is `notes create`. Path parameters are arguments and the other input fields are flags. `--help` prints the doc comment, the flags and an example:
```
$ go run . notes create --help
notes notes create [flags]
Stores a note and returns it with its id.
POST /v1/notes
Flags:
--text string Text is the note body. (required)
Example:
notes notes create --text "buy milk"
```
A client that links none of the service builds the same commands from the served document with `zip.CommandsFromSpec`, and sends them with `zip.Remote`:
```go
remote := zip.Remote{Base: "http://127.0.0.1:8080"}
spec, err := remote.Spec(ctx)
if err != nil {
return err
}
commands, err := zip.CommandsFromSpec(spec)
if err != nil {
return err
}
cli := &zip.CLI{Name: "notes", Commands: commands, Invoke: remote.Invoke, Out: os.Stdout}
return cli.Run(ctx, os.Args[1:])
```
```
$ go run . notes create --text "sent over HTTP"
{
"id": 4,
"text": "sent over HTTP"
}
```
`Remote.Header` sets headers, such as `Authorization`, on every request.
### Calls between services
```go
conn, err := zip.Dial("http://127.0.0.1:8080")
if err != nil {
return err
}
note, err := zip.Call[AddIn, Note](ctx, conn, "post_notes", &AddIn{Text: "over the call plane"})
```
The caller imports or restates `AddIn` and `Note` and links nothing else of the service. A refusal is a `*zip.HTTPError` carrying the callee's status. `zip.DialApp(name)` dials the unix socket at `zip.SocketPath(name)`, which is how services on one host reach each other.
This body is ZAP, laid out in the order of the struct's fields. **Add fields only at the end of an input or output struct.** Reordering, inserting or retyping a field changes the wire and breaks callers built against the old layout. REST and MCP bodies are JSON, where field order does not matter.
### SDKs
| Target | How |
|---|---|
| Go | `app.SDK("notes")` returns a `*zip.SDK`. `Source` is a package whose `Client` (`notes.Dial(addr)`, then `client.PostNotes(ctx, in)`) uses `zip.Call`; `Gaps` lists operations it could not express. |
| Rust, C++ | `app.RustSDK(crate)`, `app.CppSDK(namespace)`. |
| From a schema | `cmd/zipgen` reads a `.zap` schema, not Go code. `zip.ZAPSchema("notes", app).String()` writes one; then `go run github.com/zap-proto/zip/cmd/zipgen sdk -schema notes.zap -lang go -pkg notes -o client.go`. `-lang` also takes `rust` and `cpp`, and the other subcommands are `mcp`, `cli`, `docs` and `zap`. |
| Other languages | Generate from `/.well-known/openapi.json`. |
### Errors
Every refusal is an RFC 9457 problem document under `application/problem+json`:
```sh
curl -s -X POST http://127.0.0.1:8080/v1/notes -H 'Content-Type: application/json' -d '{}'
```
```json
{"detail":"field \"text\" is required","status":400,"title":"Bad Request","type":"about:blank"}
```
- A handler returns `zip.ErrBadRequest(msg)`, `ErrUnauthorized`, `ErrForbidden`, `ErrNotFound`, `ErrConflict`, `ErrPaymentRequired`, `ErrUnprocessable`, `ErrInternal`, or `zip.Errorf(status, format, args...)`. `&zip.HTTPError{Status: 409, Code: "taken", Msg: "..."}` adds `code`, and `.With(map[string]any{"cap": 5000})` adds members beside the standard ones.
- Any other error is a 500 whose `detail` is `err.Error()`. Wrap errors whose text a caller should not read.
- An address declared through `zip.OAuth(router)` answers RFC 6749 `{"error", "error_description"}` under `application/json` instead.
- `zip.Config{ErrorHandler: ...}`, a `fiber.ErrorHandler` from `github.com/zap-proto/fiber/v3`, replaces the writer for every refusal.
- Over MCP a refusal is `isError` content; `CLI.Run` returns it as an error.
Problem documents arrived in v1.32.0. Through v1.31.4, v1.18.x included, a refusal was `{"status":400,"code":"...","error":"..."}` under `application/json`; code that reads `error` reads `detail` now.
### Security
zip validates no tokens. It reads identity from headers set by the gateway in front of it, after the gateway has checked the Hanzo IAM token: `X-Org-Id`, `X-User-Id`, `X-User-Email` and the others named by the `zip.Header*` constants. `zip.CallerOf(ctx)` returns them in a handler. A service reachable without that gateway in front must not trust these headers.
`app.Authorize` installs one rule that runs for every operation after its input is decoded and validated and before the handler, however the operation was reached: REST, MCP, `zip.Call` or the in-process CLI.
```go
app.Authorize(func(ctx context.Context, op zip.Op, in any) (zip.Decision, error) {
if zip.CallerOf(ctx).Org == "" {
return zip.Decision{Effect: zip.Deny, Clause: "org", Reason: "no org on the request"}, nil
}
return zip.Decision{Effect: zip.Allow}, nil
})
```
A request to the example without `X-Org-Id` is then refused with a 403:
```json
{"code":"org","detail":"no org on the request","status":403,"title":"Forbidden","type":"about:blank"}
```
- `zip.Deny` is a 403 with `Clause` as `code` and `Reason` as `detail`. `zip.Approve` holds the operation and answers 202 with a `zip.Approval`; a Go caller reads it with `zip.HeldOf(err)`. A returned error aborts the call.
- The in-process CLI has no request, so `CallerOf` is empty unless the context was built with `zip.WithCaller(ctx, zip.Caller{Org: "acme"})`.
- `app.OnResult(fn)` is told the outcome of every operation, for audit records.
- `CallerOf(ctx).IP` is the socket peer. `Config.TrustProxy`, `TrustedProxies` and `ProxyHeader` opt in to an address a trusted proxy forwards.
## Testing
`TestEveryOperationIsInEveryProjection` in [`examples/local-service/main_test.go`](examples/local-service/main_test.go) reads the router and checks each operation against the OpenAPI document, the MCP tool list and the CLI, so an operation added later is covered with no change to the test:
```go
for _, r := range app.Routes() {
if r.Op == "" {
continue // an untyped route has no projections
}
op := spec.Paths[zip.Template(r.Pattern)][strings.ToLower(r.Method)]
if op.OperationID != r.Op {
t.Errorf("%s %s: OpenAPI operationId %q, want %q", r.Method, r.Pattern, op.OperationID, r.Op)
}
if !tools[r.Op] {
t.Errorf("%s %s: no MCP tool %q", r.Method, r.Pattern, r.Op)
}
if !commands[r.Op] {
t.Errorf("%s %s: no CLI command %q", r.Method, r.Pattern, r.Op)
}
}
```
## Upgrading
| Version | Change |
|---|---|
| v1.32.0 | Refusals are RFC 9457 problem documents under `application/problem+json`. Before, `{"status","code","error"}` under `application/json`. |
| v1.36.48 | The OpenAPI document declares a `default` refusal response on every operation. MCP `initialize` answers the revision the client asked for. Doc comments reach binaries whose handlers are in package main; before, they did only under `go test`. |
v1.36.21 is retracted.
## Repository
| Path | |
|---|---|
| `examples/local-service` | the service above, a module of its own |
| `examples/hello`, `examples/zap-typed` | smaller typed services |
| `examples/sse-streaming`, `examples/websocket` | the two untyped routes, each saying why |
| `examples/migrate-from-chi`, `-gin`, `-beego` | existing apps moved onto zip |
| `cmd/zipdoc`, `cmd/zipgen` | doc comments; code generation from `.zap` schemas |
| `middleware` | `Recover`, `RequestID`, `Timeout`, `MaxBody`, `CORS`, `RateLimit`, `NewBreaker`, `ProductionHeaders` |
| `wsx`, `js` | WebSocket upgrades; JavaScript and TypeScript handlers run in goja |
| `LLM.md` | notes for changing zip itself |
## License
MIT — see [LICENSE](./LICENSE).
MIT. See [LICENSE](LICENSE).
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"notes": {
"type": "http",
"url": "http://127.0.0.1:8080/mcp"
}
}
}
+61
View File
@@ -0,0 +1,61 @@
module github.com/zap-proto/zip/examples/local-service
go 1.26.8
require (
github.com/hanzoai/sqlite v0.5.10
github.com/zap-proto/zip v1.36.47
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/hanzoai/csqlite v0.1.2 // indirect
github.com/hanzoai/sqlcipher v0.1.1 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/crypto v1.20.2 // indirect
github.com/luxfi/log v1.4.3 // indirect
github.com/luxfi/mdns v0.1.1 // indirect
github.com/luxfi/metric v1.10.0 // indirect
github.com/luxfi/zap v1.2.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/zap-proto/go v1.8.3 // indirect
github.com/zap-proto/http v0.3.5 // indirect
github.com/zap-proto/mcp v1.0.5 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.51.0 // indirect
)
// The example runs against this checkout, so a change to zip and the example
// that uses it are tested together. A copy outside the repository drops this line.
replace github.com/zap-proto/zip => ../..
tool github.com/zap-proto/zip/cmd/zipdoc
+157
View File
@@ -0,0 +1,157 @@
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 h1:UBg1xk+oAsIVbFuGg6hdfAm7EvCv3EL80vFxJNsslqw=
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/hanzoai/csqlite v0.1.2 h1:4DzdgUfKADhzEP66Y30/HwepuBLrwt7NsemJMfrPGl4=
github.com/hanzoai/csqlite v0.1.2/go.mod h1:H31a/O6VXuklR9UBkgY++bmAK5uzVfXPqU0F6P9Wsos=
github.com/hanzoai/sqlcipher v0.1.1 h1:GARjSiUEa1lwhd1/f87XRaujZBG5s1ZwxrZW2Es/ADI=
github.com/hanzoai/sqlcipher v0.1.1/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
github.com/hanzoai/sqlite v0.5.10 h1:TY1YjsS+2ybZ6CKZKpHY01kT0aGdYQBUad2vJV88Jl8=
github.com/hanzoai/sqlite v0.5.10/go.mod h1:6MOvr13PLL+ajbg6SGf1nSvqDsCNzKIvwrLo429LFUY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
github.com/luxfi/crypto v1.20.2 h1:L81WEsU/hs2A76F5PWBusG0yU74QqkDdUqqgexWUxh4=
github.com/luxfi/crypto v1.20.2/go.mod h1:qYHOM0lO4PRh7LEaObxFQUIMjmT1/paVm/WgZkobT1k=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
github.com/luxfi/metric v1.10.0 h1:Ca4N6HqukMoZ1JUx3t5BJ4cjyY3HL+HnF9kaGEAAUfA=
github.com/luxfi/metric v1.10.0/go.mod h1:JhVJPOM10FEpGSSaP8QrGLi7w0D3pjJ2kwWwmGi+9YI=
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
github.com/luxfi/zap v1.2.7 h1:LzYit+1xLoQRT0ahYsoYcdoDz5I5gc0ltLvBXRkPpwQ=
github.com/luxfi/zap v1.2.7/go.mod h1:cAoQ1g80iSxgWOhXj6YBrWfKJi771FjPe8HdQ2eOLZw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6Jbg=
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
github.com/zap-proto/go v1.8.3 h1:GWhb8y7ylgVcaVOR8XN/It56Xovm5Mkv+nQqiblnnoA=
github.com/zap-proto/go v1.8.3/go.mod h1:fbNeWcssRRbTkQEDCrmUmzqJA1gKcAlgi1z4QrtEFZg=
github.com/zap-proto/http v0.3.5 h1:ezSuUbF05jxFGpfmScBnzIVTeH1ZGhcDSm7d5yL8Byc=
github.com/zap-proto/http v0.3.5/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/mcp v1.0.5 h1:1Hi0KNi4YlWW2zkQUMDK9n3jP3wZaRJ1rseMW9p9F0c=
github.com/zap-proto/mcp v1.0.5/go.mod h1:cYOsq+33NyW4X8nUVtIs/qtezVovYazw1s0V7mrQBYA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+106
View File
@@ -0,0 +1,106 @@
// Command local-service is a zip service with one operation, backed by SQLite.
//
// The operation is declared once, in New, and every interface below is derived
// from that declaration:
//
// go run . serve # REST, MCP and OpenAPI on 127.0.0.1:8080
// go run . notes create --text "buy milk" # the same operation as a command
// go run . openapi openapi.json # the OpenAPI document, written to a file
//
// Notes are stored in notes.db in the working directory.
package main
//go:generate go tool zipdoc
import (
"context"
"database/sql"
"fmt"
"os"
"github.com/hanzoai/sqlite"
"github.com/zap-proto/zip"
)
// Note is one stored note.
type Note struct {
// ID is the row id SQLite assigned.
ID int64 `json:"id"`
// Text is the note as written.
Text string `json:"text"`
}
// AddIn is a note to store.
type AddIn struct {
// Text is the note body.
Text string `json:"text" validate:"required"`
}
// Store keeps notes in one SQLite file.
type Store struct {
db *sql.DB
}
// Open opens the SQLite file at path, creating it and its table if needed.
func (s *Store) Open(path string) error {
db, err := sqlite.OpenDB(path, nil)
if err != nil {
return err
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT NOT NULL)`); err != nil {
db.Close()
return err
}
s.db = db
return nil
}
// Add stores a note and returns it with its id.
//
// Example: {"text": "buy milk"}
// Response: {"id": 1, "text": "buy milk"}
func (s *Store) Add(ctx context.Context, in *AddIn) (*Note, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO notes (text) VALUES (?)`, in.Text)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return &Note{ID: id, Text: in.Text}, nil
}
// New declares the service's operations. It does not touch the database, so the
// OpenAPI document can be written by a build step that has none.
func New(s *Store) *zip.App {
app := zip.New(zip.Config{AppName: "notes"})
zip.Post(app, "/v1/notes", s.Add)
return app
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
s := &Store{}
app := New(s)
// `local-service openapi <file>` and `local-service declare <file>` write a
// projection and stop here, before the database is opened.
if done, err := app.Described(); done {
return err
}
if err := s.Open("notes.db"); err != nil {
return err
}
if len(os.Args) > 1 && os.Args[1] == "serve" {
return app.Listen("http://127.0.0.1:8080")
}
cli := app.CLI()
cli.Out = os.Stdout
return cli.Run(context.Background(), os.Args[1:])
}
+163
View File
@@ -0,0 +1,163 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// newService is the service over a fresh SQLite file.
func newService(t *testing.T) (*zip.App, *Store) {
t.Helper()
s := &Store{}
if err := s.Open(filepath.Join(t.TempDir(), "notes.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.db.Close() })
return New(s), s
}
// call sends one HTTP request to the app without opening a socket.
func call(t *testing.T, app *zip.App, method, path, body string) (int, string, string) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, resp.Header.Get("Content-Type"), string(b)
}
// TestEveryOperationIsInEveryProjection reads the router and checks that each
// typed operation is also an OpenAPI operation, an MCP tool and a CLI command,
// under one id. An operation added to New is covered with no change here.
func TestEveryOperationIsInEveryProjection(t *testing.T) {
app, _ := newService(t)
_, _, body := call(t, app, "GET", zip.SpecPath, "")
var spec struct {
Paths map[string]map[string]struct {
OperationID string `json:"operationId"`
Description string `json:"description"`
} `json:"paths"`
}
if err := json.Unmarshal([]byte(body), &spec); err != nil {
t.Fatalf("OpenAPI document: %v", err)
}
_, _, body = call(t, app, "POST", "/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
var list struct {
Result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(body), &list); err != nil {
t.Fatalf("tools/list: %v", err)
}
tools := map[string]bool{}
for _, tool := range list.Result.Tools {
tools[tool.Name] = true
}
commands := map[string]bool{}
for _, c := range app.Commands() {
commands[c.OperationID] = true
}
ops := 0
for _, r := range app.Routes() {
if r.Op == "" {
continue // an untyped route has no projections
}
ops++
op := spec.Paths[zip.Template(r.Pattern)][strings.ToLower(r.Method)]
if op.OperationID != r.Op {
t.Errorf("%s %s: OpenAPI operationId %q, want %q", r.Method, r.Pattern, op.OperationID, r.Op)
}
if op.Description == "" {
t.Errorf("%s %s: no description; run go generate", r.Method, r.Pattern)
}
if !tools[r.Op] {
t.Errorf("%s %s: no MCP tool %q", r.Method, r.Pattern, r.Op)
}
if !commands[r.Op] {
t.Errorf("%s %s: no CLI command %q", r.Method, r.Pattern, r.Op)
}
}
if ops == 0 {
t.Fatal("no typed operations registered")
}
}
// TestOneHandlerBehindEveryDoor calls the operation over REST, MCP and the CLI,
// then counts the rows: three doors, one handler, one table.
func TestOneHandlerBehindEveryDoor(t *testing.T) {
app, s := newService(t)
code, _, body := call(t, app, "POST", "/v1/notes", `{"text":"over REST"}`)
if code != 200 || body != `{"id":1,"text":"over REST"}` {
t.Errorf("REST: %d %s", code, body)
}
_, _, body = call(t, app, "POST", "/mcp",
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"post_notes","arguments":{"text":"over MCP"}}}`)
if !strings.Contains(body, `{\"id\":2,\"text\":\"over MCP\"}`) {
t.Errorf("MCP: %s", body)
}
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), []string{"notes", "create", "--text", "over the CLI"}); err != nil {
t.Fatalf("CLI: %v", err)
}
if !strings.Contains(out.String(), `"id": 3`) {
t.Errorf("CLI: %s", out.String())
}
var n int
if err := s.db.QueryRow(`SELECT count(*) FROM notes`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("notes table has %d rows, want 3", n)
}
}
// TestARefusalIsAProblemDocument checks the error body: RFC 9457, not a shape of
// zip's own.
func TestARefusalIsAProblemDocument(t *testing.T) {
app, _ := newService(t)
code, typ, body := call(t, app, "POST", "/v1/notes", `{}`)
if code != 400 || typ != "application/problem+json" {
t.Fatalf("empty note: %d %s %s", code, typ, body)
}
var problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail"`
}
if err := json.Unmarshal([]byte(body), &problem); err != nil {
t.Fatal(err)
}
if problem.Type != "about:blank" || problem.Title != "Bad Request" || problem.Status != 400 || problem.Detail == "" {
t.Errorf("problem document: %s", body)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Code generated by zipdoc; DO NOT EDIT.
package main
import (
"encoding/json"
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("github.com/zap-proto/zip/examples/local-service POST /v1/notes", zip.Doc{
Description: "Stores a note and returns it with its id.",
Fields: map[string]string{
"AddIn.text": "Text is the note body.",
"Note.id": "ID is the row id SQLite assigned.",
"Note.text": "Text is the note as written.",
},
Example: json.RawMessage(`{"text":"buy milk"}`),
Response: json.RawMessage(`{"id":1,"text":"buy milk"}`),
})
}
+5
View File
@@ -32,3 +32,8 @@ test:
# deliberately separate declarations of the batch shapes still describe one
# thing. See contract/doc.go.
run: cd contract && go test -count=1 -timeout 5m ./...
- name: go-test-local-service
# examples/local-service is its own module so SQLite stays out of zip's
# graph. It checks its generated doc file is current, then asserts its op
# answers over REST, MCP, OpenAPI and the CLI.
run: cd examples/local-service && go tool zipdoc -check && go test -count=1 -timeout 5m ./...