ZAP Protocol

zip

The ZAP-native web framework for Go — one app, one Listen verb, ZAP primary and HTTP extra, with OpenAPI and MCP projected from the same routes.

zip

zap-proto/zip is the web framework of the ZAP stack: a Go framework with a Fiber v3 / fasthttp core, a Sinatra-style routing API, and ZAP as the primary transport. HTTP is not the product — it is a secondary view of the same routes, served through an adapter when you ask for it.

One framework for every Go binary. Routes are defined once and served over every transport you listen on, and typed routes project for free into an OpenAPI document and an MCP tool surface.

package main

import (
    "github.com/zap-proto/zip"
    "github.com/zap-proto/zip/middleware"
)

func main() {
    app := zip.New(zip.Config{})
    app.Use(middleware.Recover(), middleware.RequestID())

    app.Get("/health", func(c *zip.Ctx) error {
        return c.JSON(200, map[string]string{"status": "ok"})
    })

    _ = app.Listen(":9653", "http://:8080") // ZAP primary + HTTP extra, one verb
}
go get github.com/zap-proto/zip

Transport is a value, not a method

There is one verb, app.Listen(addrs...), and the address scheme selects the transport — mirroring net.Listen(network, addr):

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("quic://:443")             // any RegisterTransport'd protocol

ZAP (TLS 1.3 + post-quantum KEM) is the default: a bare address with no scheme:// uses it. HTTP is built in. zip.RegisterTransport(scheme, fn) slots in any future termination/serialization protocol with zero change to the Listen API — the one extension point. Every address is served concurrently from a single call; Listen blocks until a listener stops or the first one errors.

Your routes are the surface — the same handlers, middleware, auth, and error handling run over every transport. There are no per-transport methods and no second router.

Handlers and Ctx

A handler is func(c *zip.Ctx) error. zip.Ctx wraps the underlying fiber.Ctx and adds request/response sugar and identity accessors. Returning a *zip.HTTPError sets the response status; any other error becomes a 500 JSON body through the default error handler.

app.Get("/v1/users/:id", func(c *zip.Ctx) error {
    id := c.Param("id")
    if id == "" {
        return zip.ErrBadRequest("missing id")
    }
    return c.JSON(200, map[string]string{
        "id":   id,
        "org":  c.Org(),  // gateway-minted X-Org-Id
        "user": c.User(), // gateway-minted X-User-Id
    })
})
GroupMethods
RequestMethod() · Path() · Param(name) · Query(name) · Header(name) · Body()
Bind + validateBind(v) · BindQuery(v) · BindURI(v) — parse by content type, then run required / min / max / minlen / maxlen struct-tag validation, returning 400 on failure
ResponseStatus(code) · JSON(code, v) · String(code, s) · Bytes(code, b) · NoContent(code)
StreamSendStream(r) · SendStreamWriter(fn) — chunked / Server-Sent Events
IdentityOrg() · User() · UserEmail() · IsAdmin() · RequestID() — read JWT-validated X-* headers set by the gateway; empty when no gateway is in front (local dev)
EscapeFiber() · App() · Context() · Log() · Locals(k, v...)

Errors are values: zip.Errorf(status, format, args...) and the shortcuts ErrBadRequest / ErrUnauthorized / ErrForbidden / ErrNotFound / ErrConflict / ErrInternal all build a *zip.HTTPError that the framework renders as {status, code, error} JSON.

One registry, three projections

Typed handlers are where zip earns its keep. zip.Get[In, Out](app, path, fn) registers one operation, and that single registration projects three ways from one internal registeredOp:

  1. REST route — the handler itself: decode body → In, validate, run fn, marshal Out → JSON.
  2. OpenAPI 3.1 — generated from the In/Out types, served at /.well-known/openapi.json with Swagger UI at /docs.
  3. MCP tool — every typed handler is automatically a Model Context Protocol tool at /mcp (JSON-RPC 2.0). tools/list projects the same JSON Schema the OpenAPI doc uses; tools/call runs the exact same fn.
type ValidateRequest struct {
    Email string `json:"email" validate:"required,minlen=3,maxlen=255"`
    Age   int    `json:"age"   validate:"required,min=18,max=120"`
}
type ValidateResponse struct {
    OK         bool   `json:"ok"`
    Normalized string `json:"normalized"`
}

zip.Post(app, "/v1/validate", func(ctx context.Context, in *ValidateRequest) (*ValidateResponse, error) {
    return &ValidateResponse{OK: true, Normalized: strings.ToLower(in.Email)}, nil
}, zip.WithSummary("Validate an email and age"), zip.WithTags("validation"))

Because /mcp is an ordinary route, it is served over every transport you Listen on — so ZAP-native MCP is automatic: an agent speaking ZAP gets the full tool surface with zero extra wiring. On by default; set Config.MCP.Disabled to suppress it. The op id (shared by OpenAPI and MCP so the two surfaces agree) defaults to method+path and is overridable with zip.WithOperationID.

For a gRPC-style named-service surface on top, zaprpc.Registry + zaprpc.HTTPHandler(reg) exposes generated zapc services by name at a route.

Route precedence — the contract

zip inherits its routing from the zap-proto/fiber fork, which makes precedence a property of the pattern, not of registration order. The rules mirror Go 1.22's net/http.ServeMux:

  • Most specific pattern wins, regardless of the order routes were registered: static literal ≻ :param* wildcard.
  • More static structure wins. A deeper static route beats a shallower wildcard; a longer literal breaks a static tie.
  • Ambiguous equal-specificity overlap panics at startup, naming both routes, instead of one silently shadowing the other.
  • Constraint-typed params are exempt from the conflict check — :id<int> and :slug match disjoint value sets, so they coexist in registration order.
  • Middleware keeps declaration order. Use and mounted sub-apps are precedence barriers; endpoints are sorted only within the run between barriers, so middleware always wraps the handlers that follow it.
// Registration order is irrelevant — the static route always wins.
app.Get("/v1/iam/*",    wildcard) // registered first
app.Get("/v1/iam/keys", static)   // registered second

// GET /v1/iam/keys          -> static
// GET /v1/iam/anything-else -> wildcard
// The full ladder: static ≻ param ≻ wildcard, any registration order.
app.Get("/users/*",   wildcard)
app.Get("/users/:id", param)
app.Get("/users/me",  static)

// GET /users/me   -> static
// GET /users/42   -> param
// GET /users/a/b  -> wildcard
// Two distinct, equally specific, unconstrained patterns overlap with no winner.
// This panics at registration, naming both — it never silently shadows.
app.Get("/x/:id",   h1)
app.Get("/x/:name", h2) // panic: route conflict (equal specificity, ambiguous match)

Method stacks are independent, so the same pattern on different methods never conflicts, and re-registering the same pattern merges handlers (both run) rather than shadowing. Routes added at runtime land in their correct precedence position after RebuildTree, not merely at the end. The contract is pinned by tests in both repos (specificity_test.go in zip, router_precedence_test.go in fiber). See fiber for the full rationale and the comparator.

Middleware

Middleware is zip.Handler and runs in declaration order; a middleware body ends by calling c.Continue() to chain to the next handler. The zip/middleware package ships the common set:

Recover · RequestID · Logger · Timeout · MaxBody · CORS · RateLimit · Telemetry · Breaker (circuit breaker).

app.Use(
    middleware.Recover(),
    middleware.RequestID(),
    middleware.CORS(middleware.CORSConfig{AllowOrigins: []string{"*"}}),
)

Auth and identity-header handling (JWT validation, strip-and-mint of X-* identity headers) are the gateway's job, not the framework's — zip executes only the handler code its consumer registers. app.UseFiber(...) accepts raw fiber.Handler middleware for the fiber/v3/middleware/* packages when needed.

Runtime mounts — extension routes

app.Module mounts a sandboxed extension as a route. The "METHOD /path" form matches the Sinatra idiom; the runtime selects the backing engine; the module path holds the extension manifest:

app.Module("POST /v1/policy/eval", "wasm",     "./extensions/policy")
app.Module("POST /v1/transform",   "pyvm",     "./extensions/transform")
app.Module("POST /v1/webhook",     "goja",     "./extensions/webhook")

Supported runtimes: wasm · goja · pyvm · starlark · v8go · native. The host serializes one request envelope (method, path, query, headers, body, plus identity) and the guest returns a {status, headers, body} envelope — the same shape across every engine. Mounting requires a Config.Loader; the loader interface is duck-typed, so zip stays decoupled from any specific runtime implementation. Config.AllowedRuntimes restricts which engines a service will accept.

A legacy TypeScript/JavaScript handler can also run in-process via the embedded JS runtime (runtime.JSHandler / runtime.JSModule) — goja executes the code (pure Go, no CGO) and esbuild transpiles TS ahead of it — so an Express-shaped (req, res) function becomes a route with no rewrite, then hot paths migrate to native Go one at a time.

Composition: services are Mounts

A service built on zip is a set of subsystem packages, each exposing one contract:

func Mount(app *zip.App, deps Deps) error

Deps is a typed dependency bag the binary builds once and threads through every Mount. A binary is a selection of subsystems mounted into one App and served by one Listen — so a standalone service and a fused multi-subsystem binary are the same code with a different selection. This scales: a production control plane composes tens of thousands of lines of subsystem code (identity, secrets, gateway, billing, AI, and more) over a compose root well under a thousand lines of zip — see the named adopters on Ecosystem.

Migration is the same shape. app.Mount("/legacy", h) accepts any http.Handler (chi, gin, beego, net/http), and zip.AdaptNetHTTP / AdaptNetHTTPFunc / AdaptNetHTTPMiddleware wrap standard handlers and middleware — so an existing service becomes a subsystem before it becomes ZAP-native. Adapters cost a few percent versus native dispatch; replace them with native handlers when feasible.

JSON at the edge

Every JSON path — c.JSON, Bind, the typed round-trip, the module envelope, the OpenAPI spec — goes through one internal helper. When the binary is compiled with GOEXPERIMENT=jsonv2 (Go 1.25+) that helper is backed by the standard library's encoding/json/v2; otherwise it falls back to encoding/json. There is no third-party JSON library — stdlib only. zip.JSONVariant reports which implementation is active, and zip.New logs it once at startup. JSON is the edge format; between subsystems you pass ZAP-typed Go values.

Install and versioning

go get github.com/zap-proto/zip

Module path github.com/zap-proto/zip; released on semver tags (vX.Y.Z). It builds on github.com/zap-proto/fiber/v3 (the routing engine) and github.com/zap-proto/go + github.com/zap-proto/http (the ZAP transport family). Requires Go 1.26+. Licensed MIT.

  • fiber — the routing engine zip is built on, and its precedence rules
  • Transports — ZAP as the native wire, HTTP as the bridge
  • HTTP over ZAP — the transport zip's HTTP adapter and ZAP primary share
  • MCP — the tool surface every typed route projects
  • Gateway — the edge in front of a zip fleet
  • Ecosystem — who runs zip in production

On this page