mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
Co-authored-by: Hanzo Dev <[email protected]>
187 lines
7.0 KiB
Go
187 lines
7.0 KiB
Go
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)
|
|
delete(resp, "default") // the refusal every op publishes; this reads the success
|
|
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})
|
|
a.Post("/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})
|
|
a.Post("/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})
|
|
a.Post("/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})
|
|
a.Post("/v1/st/plain", mk)
|
|
a.Post("/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"])
|
|
}
|
|
}
|
|
|
|
// DOCTRINE CHANGED. WithStatus used to refuse every non-2xx at declaration, on
|
|
// the grounds that an error status is the error a handler returns and two places
|
|
// for one fact are free to disagree.
|
|
//
|
|
// That held while an op's only way to answer a failure was the standard
|
|
// {status, code, error} envelope. It stopped holding for an op whose failure
|
|
// carries a TYPED BODY the envelope cannot express — a 409 with the conflicting
|
|
// record, a 404 with what was searched for. Refusing those forced a hand-rolled
|
|
// middleware to write the status, which is the invisible side channel this
|
|
// package keeps closing.
|
|
//
|
|
// So a declared non-2xx is now accepted, and the doctrine survives where it was
|
|
// actually load-bearing: an error RETURN still renders the envelope, and that is
|
|
// still the way to answer a failure that has nothing extra to say. What is
|
|
// refused is only what was never an HTTP status.
|
|
func TestWithStatus_AcceptsADeclaredNonSuccessCode(t *testing.T) {
|
|
for _, code := range []int{404, 409, 302} {
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Errorf("WithStatus(%d) was refused: %v — an op may answer a failure with its own typed body", code, r)
|
|
}
|
|
}()
|
|
zip.WithStatus(code)
|
|
}()
|
|
}
|
|
// Still refused: not an HTTP status at all.
|
|
for _, code := range []int{0, 99, 600} {
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("WithStatus(%d) was accepted; that is not an HTTP status", 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})
|
|
a.Post("/v1/st/things", mk, zip.WithStatus(201), zip.WithOperationID("st_make"))
|
|
if err := a.Build(); err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|