mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user