typed: WithStatus — a 201 the document can see

zip wrote 200, or 204 for a nil Out, and had no vocabulary for anything else. A
service that answers 201 Created therefore had to reach around the framework and
set the status per request, from inside the handler. That works on the wire and
nowhere else: the status is a CONTRACT detail written into a side channel no
projection can read, so the document says 200, every generated SDK says 200, and
the route has always sent 201.

It is the same failure as a query parameter's required-ness being invisible —
a contract detail that exists only at run time is not a contract. So the status
is declared on the op, where the registry can project it:

	zip.Post(app, "/v1/things", create, zip.WithStatus(201))

op.Status keys the document's responses object, so a client generated from it
expects what the service sends. The schema rides along unchanged — a 201
describes its body exactly as a 200 does — and an op that declares nothing keeps
the old defaults byte for byte, so no existing document churns.

HTTP-only by construction. The REST route and the document read it; the MCP tool,
the CLI command and the op-call plane are untouched, because each carries its own
outcome. A void op keeps the status it declared rather than falling back to 204:
the declaration is what the caller was promised.

A non-2xx panics at declaration rather than failing at request time. An error
status is the error a handler returns (ErrNotFound and friends); letting a
declaration state one too would be two places for one fact, free to disagree.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-28 18:29:20 -07:00
co-authored by Hanzo Dev
parent 9d386ae349
commit ff7fe451d6
4 changed files with 243 additions and 5 deletions
+21
View File
@@ -54,6 +54,7 @@ any of them.
| verb | what it does |
|---|---|
| **`zip.Get/Post[In,Out](on, …)`** | **declare a typed op — THE way to declare a route.** The schema, and what every projection is derived from. `on` is the App or any Router of it |
| `zip.WithStatus(201)` | declare the SUCCESS status; it keys the document's response, not just the wire |
| `app.Add(svcs...)` | compose units of functionality |
| `app.Listen(addrs...)` | serve here; the address scheme picks the transport |
| `app.Mount(prefix, addr)` | delegate there; same scheme registry, opposite direction |
@@ -300,6 +301,26 @@ rather than silently shadowing. A `Mount` is an **ordinary wildcard route**, so
a more specific route registered afterwards still wins — pinned by
`TestMount_StaticBeatsRemoteMount`.
## The status is part of the contract (v1.18.2)
`WithStatus(code)` declares the status a successful op answers with — 201 for an
op that creates, 202 for one that accepts work it has not finished. Without it an
op answers 200, or 204 when the handler returns a nil Out.
It is an OpOption rather than something a handler sets per request because the
status is a CONTRACT detail, and the contract is what the registry projects.
`op.Status` keys the document's `responses` object, so a generated SDK expects
the status the service actually sends. A handler that reached around the
framework to set 201 wrote that detail into a side channel no projection could
read: the document said 200, every SDK said 200, and the wire said 201. That is
the same failure as a query parameter's required-ness being invisible — a
contract detail that only exists at run time is not a contract.
It is HTTP-only by construction: the REST route and the document read it, and the
MCP tool, the CLI command and the op-call plane are untouched, because each of
those carries its own outcome. A non-2xx panics at declaration — an error status
is the error a handler returns, and two places to say it is one too many.
## The schema derivation — one type, one definition (v1.17.8)
`openapi.go`. `schemaOf(t, reg, fields)` is the ONE derivation from a Go type to
+27 -4
View File
@@ -1,8 +1,10 @@
package zip
import (
"cmp"
"encoding/json"
"maps"
"net/http"
"reflect"
"sort"
"strconv"
@@ -192,21 +194,26 @@ func (a *App) buildOpenAPI() map[string]any {
opObj["parameters"] = decls
}
// 200 response.
// The success response, keyed on the status the op DECLARED — the whole
// reason WithStatus is on the op rather than set per request: a 201 that
// only reached the wire would leave every generated client expecting a
// 200 the service never sends.
if op.OutType != nil && typeName(op.OutType) != "" {
respMedia := map[string]any{"schema": schemaOf(op.OutType, reg, docFields(hasDoc, doc))}
if hasDoc && len(doc.Response) > 0 {
respMedia["example"] = json.RawMessage(doc.Response)
}
code := cmp.Or(op.Status, 200)
opObj["responses"] = map[string]any{
"200": map[string]any{
"description": "ok",
strconv.Itoa(code): map[string]any{
"description": statusText(code),
"content": map[string]any{"application/json": respMedia},
},
}
} else {
code := cmp.Or(op.Status, 204)
opObj["responses"] = map[string]any{
"204": map[string]any{"description": "no content"},
strconv.Itoa(code): map[string]any{"description": statusText(code)},
}
}
@@ -555,6 +562,22 @@ func rootSchemaOf(t reflect.Type, fields map[string]string) map[string]any {
return root
}
// statusText describes one response. The two defaults keep the words they have
// always had, so a document that never declared a status does not churn; anything
// declared gets the standard reason phrase, lowercased to match them.
func statusText(code int) string {
switch code {
case 200:
return "ok"
case 204:
return "no content"
}
if s := http.StatusText(code); s != "" {
return strings.ToLower(s)
}
return "ok"
}
// exampleFields splits an op's example object into its fields, so a parameter
// can carry the value the example gave it. A malformed or absent example yields
// nothing rather than failing the document — the schema is still true.
+160
View File
@@ -0,0 +1,160 @@
package zip_test
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// The status a successful op answers with is part of its CONTRACT, so it is
// declared on the op and projected from the registry like everything else. Before
// WithStatus, zip wrote 200 (204 for a nil Out) and had no vocabulary for
// anything else, so a service that answers 201 Created had to reach around the
// framework and set it per request — a side channel that no projection could
// see. The document said 200, every generated SDK said 200, and the wire said
// 201.
type mkIn struct {
Name string `json:"name"`
}
type mkOut struct {
ID string `json:"id"`
}
func mk(_ context.Context, in *mkIn) (*mkOut, error) { return &mkOut{ID: in.Name}, nil }
func nilOut(context.Context, *mkIn) (*mkOut, error) { return nil, nil }
// respOf reads the one responses entry an op declares: its code and description.
func respOf(t *testing.T, a *zip.App, path, method string) (string, map[string]any) {
t.Helper()
paths, _ := a.OpenAPISpec()["paths"].(map[string]map[string]any)
item, ok := paths[path]
if !ok {
t.Fatalf("no path %q in the document", path)
}
op, _ := item[method].(map[string]any)
resp, _ := op["responses"].(map[string]any)
if len(resp) != 1 {
t.Fatalf("responses = %v, want exactly one declared status", resp)
}
for code, body := range resp {
b, _ := body.(map[string]any)
return code, b
}
return "", nil
}
// The declared status reaches the WIRE.
func TestWithStatus_ReachesTheWire(t *testing.T) {
a := zip.New(zip.Config{AppName: "st", DisableStartupMessage: true})
zip.Post(a, "/v1/st/things", mk, zip.WithStatus(201))
code, body := call2(t, a, "POST", "/v1/st/things", `{"name":"x"}`)
if code != 201 {
t.Fatalf("status = %d, want 201", code)
}
if !strings.Contains(body, `"id":"x"`) {
t.Errorf("body = %s, want the handler's result — a status must not cost the body", body)
}
}
// …and the DOCUMENT, which is the whole point. A status that only changed the
// wire would be a prettier side channel, not a contract: an SDK generated from a
// document that says 200 treats a 201 as unexpected.
func TestWithStatus_ReachesTheDocument(t *testing.T) {
a := zip.New(zip.Config{AppName: "st", DisableStartupMessage: true})
zip.Post(a, "/v1/st/things", mk, zip.WithStatus(201))
code, body := respOf(t, a, "/v1/st/things", "post")
if code != "201" {
t.Fatalf("responses key = %q, want %q", code, "201")
}
if body["description"] != "created" {
t.Errorf("description = %v, want the reason phrase", body["description"])
}
// The schema still rides along: a 201 describes its body like a 200 does.
content, _ := body["content"].(map[string]any)
media, _ := content["application/json"].(map[string]any)
if _, ok := media["schema"]; !ok {
t.Errorf("201 carries no schema; content = %v", content)
}
}
// A void op keeps the status it declared rather than falling back to 204: the
// declaration is what the caller was promised.
func TestWithStatus_NilOutKeepsTheDeclaredStatus(t *testing.T) {
a := zip.New(zip.Config{AppName: "st", DisableStartupMessage: true})
zip.Post(a, "/v1/st/jobs", nilOut, zip.WithStatus(202))
if code, body := call2(t, a, "POST", "/v1/st/jobs", `{"name":"x"}`); code != 202 || strings.TrimSpace(body) != "" {
t.Fatalf("status = %d body = %q, want 202 with no body", code, body)
}
if code, resp := respOf(t, a, "/v1/st/jobs", "post"); code != "202" || resp["description"] != "accepted" {
t.Errorf("document says %q %v, want 202 accepted", code, resp["description"])
}
}
// Nothing changes for an op that declares no status — the defaults are the
// defaults, and a document that never asked for this does not churn.
func TestWithStatus_DefaultsAreUnchanged(t *testing.T) {
a := zip.New(zip.Config{AppName: "st", DisableStartupMessage: true})
zip.Post(a, "/v1/st/plain", mk)
zip.Post(a, "/v1/st/void", nilOut)
if code, _ := call2(t, a, "POST", "/v1/st/plain", `{"name":"x"}`); code != 200 {
t.Errorf("status = %d, want the 200 default", code)
}
if code, _ := call2(t, a, "POST", "/v1/st/void", `{"name":"x"}`); code != 204 {
t.Errorf("status = %d, want the 204 a nil Out has always meant", code)
}
if code, resp := respOf(t, a, "/v1/st/plain", "post"); code != "200" || resp["description"] != "ok" {
t.Errorf("document says %q %v, want 200 ok", code, resp["description"])
}
}
// A status is the SUCCESS status. An error status comes from the error a handler
// returns, and letting both say it would be two places for one fact — so a
// non-2xx is refused at declaration, at boot, not at request time.
func TestWithStatus_RefusesANonSuccessCode(t *testing.T) {
for _, code := range []int{404, 500, 302, 199} {
func() {
defer func() {
if recover() == nil {
t.Errorf("WithStatus(%d) was accepted; a non-2xx must be refused at declaration", code)
}
}()
zip.WithStatus(code)
}()
}
}
// The status is an HTTP notion, so the projections that are not HTTP are
// untouched: an MCP tools/call still answers with the op's result.
func TestWithStatus_MCPIsUnaffected(t *testing.T) {
a := zip.New(zip.Config{AppName: "st", DisableStartupMessage: true})
zip.Post(a, "/v1/st/things", mk, zip.WithStatus(201), zip.WithOperationID("st_make"))
a.Prepare()
code, body := call2(t, a, "POST", "/mcp",
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"st_make","arguments":{"name":"x"}}}`)
if code != 200 {
t.Fatalf("mcp status = %d, want 200 — JSON-RPC carries its own outcome", code)
}
var res struct {
Result struct {
Content []struct{ Text string } `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(body), &res); err != nil {
t.Fatalf("mcp body not json: %v — %s", err, body)
}
if res.Result.IsError || len(res.Result.Content) == 0 || !strings.Contains(res.Result.Content[0].Text, `"id":"x"`) {
t.Fatalf("mcp result = %+v, want the op's own output", res.Result)
}
}
+35 -1
View File
@@ -1,6 +1,7 @@
package zip
import (
"cmp"
"context"
"reflect"
"strconv"
@@ -31,6 +32,7 @@ type registeredOp struct {
Path string
OperationID string
Summary string
Status int // the success status; 0 means the default (200, or 204 for a nil Out)
Tags []string
InType reflect.Type
OutType reflect.Type
@@ -79,6 +81,33 @@ func WithOperationID(id string) OpOption {
return func(op *registeredOp) { op.OperationID = id }
}
// WithStatus declares the status a SUCCESSFUL op answers with — 201 for an op
// that creates a resource, 202 for one that accepts work it has not finished.
// Without it an op answers 200, or 204 when its handler returns a nil Out.
//
// It is declared on the op because the status is part of the CONTRACT, and the
// contract is what the registry projects: the OpenAPI document keys its response
// on this code, so a generated SDK expects the status the service actually
// sends. Setting it per request instead — reaching around the framework from
// inside a handler — writes a contract detail into a side channel no projection
// can read, which is how a document comes to say 200 about a route that has
// always answered 201.
//
// The status is an HTTP notion, so it applies to the REST route and the document
// and to nothing else: an MCP tools/call, a CLI command and a zip.Call all carry
// their own outcome and are untouched.
//
// A non-2xx is refused here, at declaration, rather than at request time. An
// error status comes from the error a handler returns ([ErrNotFound] and
// friends); letting a declaration state one too would be two places for one
// fact, free to disagree.
func WithStatus(code int) OpOption {
if code < 200 || code > 299 {
panic("zip: WithStatus wants a 2xx success status — an error status is the error a handler returns")
}
return func(op *registeredOp) { op.Status = code }
}
// bindURL copies URL-borne values onto the decoded input, matching a name to the
// field whose json tag (else field name, case-insensitively) equals it. It is the
// ONE binder for both URL sources — path params and query params are the same
@@ -258,9 +287,14 @@ func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandle
return err
}
if out == nil {
c.Status(204)
// A void op answers with the status it DECLARED, else the 204 a nil
// Out has always meant.
c.Status(cmp.Or(op.Status, 204))
return nil
}
if op.Status != 0 {
c.Status(op.Status)
}
return c.JSON(out)
}
// With() middleware composes around the op only when there IS any: wrapping