mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A scope declares operations, and the verb methods are generic
Go 1.27 allows generic methods on concrete types, so a scope's Get/Post/Put/
Patch/Delete infer In and Out from the handler. A declaration carries no type
arguments and reads as what it does:
accounts := app.Scope("/v1/accounts").Tag("accounts")
accounts.Post("/", api.CreateAccount).ID("accounts.create")
Interface methods still may not declare type parameters, which is why these
live on the concrete *Scope rather than on Router. The package-level Post and
its siblings are unchanged and register through the same path, so the document,
the MCP tool, the CLI and the call plane keep reading one registry — the two
forms publish byte-identical documents, which is a test.
The handler is the bound method, passed directly, so the selector survives in
the source and the projections can still resolve a method's doc comment.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
module github.com/zap-proto/zip
|
||||
|
||||
go 1.26.8
|
||||
go 1.27.1
|
||||
|
||||
// v1.36.21 does not compile: mcp.go calls networkOf, which was staged in a
|
||||
// file the commit left behind. It built locally because the working tree held
|
||||
@@ -24,46 +24,40 @@ require (
|
||||
github.com/valyala/fasthttp v1.70.0
|
||||
github.com/zap-proto/fiber/v3 v3.2.1
|
||||
github.com/zap-proto/go v1.8.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/gorilla/rpc v1.2.1 // indirect
|
||||
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||
github.com/luxfi/accel v1.2.4 // indirect
|
||||
github.com/luxfi/crypto v1.20.2 // indirect
|
||||
github.com/luxfi/mdns v0.1.1 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
github.com/zap-proto/http v0.3.5
|
||||
github.com/zap-proto/mcp v1.0.5
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/tools v0.45.0
|
||||
)
|
||||
|
||||
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/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/gofiber/schema v1.7.1 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.4 // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/rpc v1.2.1 // indirect
|
||||
github.com/grandcat/zeroconf v1.0.0 // 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/mdns v0.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/zap-proto/http v0.3.5
|
||||
github.com/zap-proto/mcp v1.0.5
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package zip
|
||||
|
||||
// A SCOPE is a path prefix and what holds under it; an OPERATION is one typed
|
||||
// route declared there. The verb methods below are GENERIC METHODS on a
|
||||
// concrete type, which Go 1.27 allows, so In and Out are inferred from the
|
||||
// handler and the caller writes no type arguments at all:
|
||||
//
|
||||
// accounts := app.Scope("/v1/accounts").Tag("accounts")
|
||||
//
|
||||
// accounts.Get("/", api.ListAccounts)
|
||||
// accounts.Post("/", api.CreateAccount).ID("accounts.create")
|
||||
// accounts.Get("/:id", api.GetAccount)
|
||||
//
|
||||
// Interface methods still may not declare type parameters — that part of the
|
||||
// restriction did not move — which is why these live on *Scope and not on
|
||||
// [Router]. [Post] and its siblings remain the low-level form; both register
|
||||
// the same op through the same path, so a service may hold either and the
|
||||
// document, the MCP tool, the CLI and the call plane read one registry.
|
||||
//
|
||||
// THE HANDLER IS THE BOUND METHOD. It is passed directly rather than wrapped in
|
||||
// a closure or built by a factory, so `api.CreateAccount` survives in the source
|
||||
// where the projections can resolve it back to the method and its doc comment. A
|
||||
// wrapper erases that, and with it the documentation the operation publishes.
|
||||
|
||||
// Scope is a prefix and the options every operation declared under it carries.
|
||||
// It is created by [App.Scope] and nests with [Scope.Scope].
|
||||
type Scope struct {
|
||||
on Router
|
||||
opts []OpOption
|
||||
}
|
||||
|
||||
// Scope opens a scope at prefix. Middleware passed here applies to every route
|
||||
// under it, the same as [App.Group], which is what it opens the scope on.
|
||||
func (a *App) Scope(prefix string, mw ...Handler) *Scope {
|
||||
return &Scope{on: a.Group(prefix, mw...)}
|
||||
}
|
||||
|
||||
// Scope nests a scope inside this one: the prefixes compose, and the outer
|
||||
// scope's options are carried into the inner one rather than shared with it, so
|
||||
// a tag added below does not reach back up.
|
||||
func (s *Scope) Scope(prefix string, mw ...Handler) *Scope {
|
||||
return &Scope{on: s.on.Group(prefix, mw...), opts: append([]OpOption(nil), s.opts...)}
|
||||
}
|
||||
|
||||
// Tag adds tags to every operation declared under this scope.
|
||||
func (s *Scope) Tag(tags ...string) *Scope {
|
||||
s.opts = append(s.opts, func(op *registeredOp) { op.Tags = append(op.Tags, tags...) })
|
||||
return s
|
||||
}
|
||||
|
||||
// With carries any [OpOption] as a default for this scope's operations. An
|
||||
// option the operation itself sets is applied after this one and wins.
|
||||
func (s *Scope) With(opts ...OpOption) *Scope {
|
||||
s.opts = append(s.opts, opts...)
|
||||
return s
|
||||
}
|
||||
|
||||
// Router exposes the scope's untyped surface, for the routes that are not a
|
||||
// typed request and response: a webhook that answers bytes, a stream, an
|
||||
// upgrade. Those have no In and Out to infer and are not projected — see
|
||||
// [registeredOp].
|
||||
func (s *Scope) Router() Router { return s.on }
|
||||
|
||||
// Get declares a GET operation under this scope.
|
||||
func (s *Scope) Get[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
||||
return s.declare("GET", path, fn, opts)
|
||||
}
|
||||
|
||||
// Post declares a POST operation under this scope.
|
||||
func (s *Scope) Post[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
||||
return s.declare("POST", path, fn, opts)
|
||||
}
|
||||
|
||||
// Put declares a PUT operation under this scope.
|
||||
func (s *Scope) Put[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
||||
return s.declare("PUT", path, fn, opts)
|
||||
}
|
||||
|
||||
// Patch declares a PATCH operation under this scope.
|
||||
func (s *Scope) Patch[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
||||
return s.declare("PATCH", path, fn, opts)
|
||||
}
|
||||
|
||||
// Delete declares a DELETE operation under this scope. A DELETE addresses what
|
||||
// it deletes with its URL and carries no request body — see [hasBody].
|
||||
func (s *Scope) Delete[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
|
||||
return s.declare("DELETE", path, fn, opts)
|
||||
}
|
||||
|
||||
// declare is the one registration the verb methods share. The depth is 2
|
||||
// because the route is attributed to the caller of the verb method, and there
|
||||
// are two frames of this package between them.
|
||||
func (s *Scope) declare[In, Out any](method, path string, fn TypedHandler[In, Out], opts []OpOption) *Operation[In, Out] {
|
||||
all := append(append([]OpOption(nil), s.opts...), opts...)
|
||||
return &Operation[In, Out]{op: registerTyped(2, s.on, method, path, fn, all...)}
|
||||
}
|
||||
|
||||
// Operation is a declared typed route. Its methods carry the metadata that
|
||||
// cannot be inferred — everything else (method, path, input, output, the
|
||||
// handler's documentation) is already known from the declaration.
|
||||
//
|
||||
// It is the same record the authorizer sees as [Op] and the document publishes;
|
||||
// this is the handle the declaring code holds.
|
||||
type Operation[In, Out any] struct{ op *registeredOp }
|
||||
|
||||
// ID sets the operation id. Without one the method and path name the operation.
|
||||
func (o *Operation[In, Out]) ID(id string) *Operation[In, Out] {
|
||||
o.op.OperationID = id
|
||||
return o
|
||||
}
|
||||
|
||||
// Summary sets the one-line summary the document publishes.
|
||||
func (o *Operation[In, Out]) Summary(s string) *Operation[In, Out] {
|
||||
o.op.Summary = s
|
||||
return o
|
||||
}
|
||||
|
||||
// Tag adds tags to this operation, after any its scope declared.
|
||||
func (o *Operation[In, Out]) Tag(tags ...string) *Operation[In, Out] {
|
||||
o.op.Tags = append(o.op.Tags, tags...)
|
||||
return o
|
||||
}
|
||||
|
||||
// Status declares the success codes this operation answers with — see
|
||||
// [WithStatus] for what more than one means.
|
||||
func (o *Operation[In, Out]) Status(codes ...int) *Operation[In, Out] {
|
||||
return o.With(WithStatus(codes...))
|
||||
}
|
||||
|
||||
// With applies any [OpOption], so the chain never becomes the only way to say
|
||||
// something the option vocabulary already says.
|
||||
func (o *Operation[In, Out]) With(opts ...OpOption) *Operation[In, Out] {
|
||||
for _, opt := range opts {
|
||||
opt(o.op)
|
||||
}
|
||||
return o
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type acct struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type acctReq struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// api is the shape a service registers: methods on a value, passed by name.
|
||||
type api struct{ made int }
|
||||
|
||||
// CreateAccount opens an account.
|
||||
func (a *api) CreateAccount(ctx context.Context, in *acctReq) (*acct, error) {
|
||||
a.made++
|
||||
return &acct{ID: "acct_1", Name: in.Name}, nil
|
||||
}
|
||||
|
||||
// GetAccount reads one account.
|
||||
func (a *api) GetAccount(ctx context.Context, in *struct{}) (*acct, error) {
|
||||
return &acct{ID: "acct_1", Name: "held"}, nil
|
||||
}
|
||||
|
||||
func newApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
return zip.New(zip.Config{AppName: "scoped", DisableStartupMessage: true})
|
||||
}
|
||||
|
||||
// The verb methods are generic METHODS, so In and Out come from the handler and
|
||||
// the declaration carries no type arguments at all.
|
||||
func TestScope_InfersFromTheHandler(t *testing.T) {
|
||||
app, a := newApp(t), &api{}
|
||||
accounts := app.Scope("/v1/accounts").Tag("accounts")
|
||||
accounts.Post("/", a.CreateAccount).ID("accounts.create").Summary("Open an account")
|
||||
accounts.Get("/:id", a.GetAccount)
|
||||
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest("POST", "/v1/accounts/", strings.NewReader(`{"name":"ada"}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("POST /v1/accounts/ = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var got acct
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Name != "ada" || a.made != 1 {
|
||||
t.Fatalf("handler ran as %+v (made=%d); the bound method must be what serves", got, a.made)
|
||||
}
|
||||
}
|
||||
|
||||
// A nested scope composes prefixes, and the route answers at the whole path.
|
||||
func TestScope_Nests(t *testing.T) {
|
||||
app, a := newApp(t), &api{}
|
||||
v1 := app.Scope("/v1")
|
||||
v1.Scope("/accounts").Get("/:id", a.GetAccount)
|
||||
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/v1/accounts/acct_1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /v1/accounts/acct_1 = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The scope's declarations and the operation's chain both reach the one record
|
||||
// every projection reads, so the document says what the code declared.
|
||||
func TestScope_MetadataReachesTheDocument(t *testing.T) {
|
||||
app, a := newApp(t), &api{}
|
||||
app.Scope("/v1/accounts").Tag("accounts").
|
||||
Post("/", a.CreateAccount).ID("accounts.create").Summary("Open an account").Tag("write")
|
||||
|
||||
doc, err := json.Marshal(app.OpenAPISpec())
|
||||
if err != nil {
|
||||
t.Fatalf("openapi: %v", err)
|
||||
}
|
||||
for _, want := range []string{"accounts.create", "Open an account", `"accounts"`, `"write"`} {
|
||||
if !strings.Contains(string(doc), want) {
|
||||
t.Fatalf("document does not carry %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The low-level form and the scope form are the same registration, so a service
|
||||
// may hold either and both are projected.
|
||||
func TestScope_MatchesThePackageLevelForm(t *testing.T) {
|
||||
a := &api{}
|
||||
viaScope := newApp(t)
|
||||
viaScope.Scope("/v1/accounts").Post("/", a.CreateAccount)
|
||||
|
||||
viaFunc := newApp(t)
|
||||
zip.Post[acctReq, acct](viaFunc.Group("/v1/accounts"), "/", a.CreateAccount)
|
||||
|
||||
s, err := json.Marshal(viaScope.OpenAPISpec())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := json.Marshal(viaFunc.OpenAPISpec())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(s) != string(f) {
|
||||
t.Fatalf("the two forms publish different documents:\n scope: %s\n func: %s", s, f)
|
||||
}
|
||||
}
|
||||
@@ -102,28 +102,28 @@ type decoder func([]byte, any) error
|
||||
// — a Group's prefix is part of the op's path, so a group-structured app
|
||||
// declares typed ops without spelling its prefix out per route.
|
||||
func Get[In, Out any](on OpTarget, path string, fn TypedHandler[In, Out], opts ...OpOption) {
|
||||
registerTyped(on, "GET", path, fn, opts...)
|
||||
registerTyped(0, on, "GET", path, fn, opts...)
|
||||
}
|
||||
|
||||
// Post registers a POST typed handler at path.
|
||||
func Post[In, Out any](on OpTarget, path string, fn TypedHandler[In, Out], opts ...OpOption) {
|
||||
registerTyped(on, "POST", path, fn, opts...)
|
||||
registerTyped(0, on, "POST", path, fn, opts...)
|
||||
}
|
||||
|
||||
// Put registers a PUT typed handler at path.
|
||||
func Put[In, Out any](on OpTarget, path string, fn TypedHandler[In, Out], opts ...OpOption) {
|
||||
registerTyped(on, "PUT", path, fn, opts...)
|
||||
registerTyped(0, on, "PUT", path, fn, opts...)
|
||||
}
|
||||
|
||||
// Patch registers a PATCH typed handler at path.
|
||||
func Patch[In, Out any](on OpTarget, path string, fn TypedHandler[In, Out], opts ...OpOption) {
|
||||
registerTyped(on, "PATCH", path, fn, opts...)
|
||||
registerTyped(0, on, "PATCH", path, fn, opts...)
|
||||
}
|
||||
|
||||
// Delete registers a DELETE typed handler at path. A DELETE addresses what it
|
||||
// deletes with its URL and carries no request body — see [hasBody].
|
||||
func Delete[In, Out any](on OpTarget, path string, fn TypedHandler[In, Out], opts ...OpOption) {
|
||||
registerTyped(on, "DELETE", path, fn, opts...)
|
||||
registerTyped(0, on, "DELETE", path, fn, opts...)
|
||||
}
|
||||
|
||||
// OpOption configures a typed handler registration (OpenAPI metadata).
|
||||
@@ -635,7 +635,7 @@ var mainPath = sync.OnceValue(func() string {
|
||||
return "main"
|
||||
})
|
||||
|
||||
func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandler[In, Out], opts ...OpOption) {
|
||||
func registerTyped[In, Out any](depth int, on OpTarget, method, path string, fn TypedHandler[In, Out], opts ...OpOption) *registeredOp {
|
||||
scope := on.OpScope()
|
||||
app := scope.App
|
||||
// The op's path is the WHOLE path — the group's prefix composed with the
|
||||
@@ -845,5 +845,6 @@ func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandle
|
||||
// router and all five projections read the same value and cannot disagree
|
||||
// about what exists — which is what makes composition a walk rather than a
|
||||
// merge of a router and a registry that were written separately.
|
||||
app.addRoute(here(2), route{method: method, path: path, serve: handler, op: op})
|
||||
app.addRoute(here(2+depth), route{method: method, path: path, serve: handler, op: op})
|
||||
return op
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user