Files
zip/typed.go
zeekay 18adb79f48 feat: route all JSON through encoding/json/v2 at the edge (HIP-0106)
Introduce internal/jsonenc package as the single JSON entry point for
every code path in zip: c.JSON, c.Bind().Body (via Fiber's pluggable
JSONEncoder/JSONDecoder), the typed zip.Get/Post[In,Out] roundtrip,
the HIP-0105 module envelope, and the auto-generated OpenAPI spec.

Two build-tag-gated files swap the implementation:
- v2.go (//go:build goexperiment.jsonv2) uses encoding/json/v2
- v1.go (//go:build !goexperiment.jsonv2) falls back to encoding/json

zip.JSONVariant is a compile-time constant naming the active impl.
zip.New logs it once at startup so operators can confirm v2 in prod
CI/k8s logs (json_variant=encoding/json/v2).

Bench numbers (Apple M1 Max, Go 1.26, fiber/v3 v3.2.0):

| Bench | json v1 ns/op | v2 ns/op | v1 allocs | v2 allocs | Δ |
|---|---|---|---|---|---|
| Edge POST roundtrip | 14972 | 13631 | 73 | 56 | -9% time, -23% allocs |
| Marshal-only | 10798 | 7924 | 34 | 34 | -27% time |
| Unmarshal-only | 13803 | 12729 | 67 | 50 | -8% time, -25% allocs |

Brand policy enforced: no third-party JSON library (no goccy/go-json,
no sonic, no jsoniter). Stdlib only per HIP-0106 canonical Hanzo Go
stack. RawMessage stays in encoding/json (v1) since the type only
lives there; both v1 and v2 honor its MarshalJSON/UnmarshalJSON so
the moduleEnvelope serializes identically under either build.
2026-05-19 11:03:26 -07:00

108 lines
3.1 KiB
Go

package zip
import (
"context"
"reflect"
"github.com/gofiber/fiber/v3"
"github.com/hanzoai/zip/internal/jsonenc"
)
// TypedHandler is the generic handler signature: takes an *In, returns
// (*Out, error). zip generates OpenAPI 3.1 spec from the In/Out types
// and registers a Fiber route that unmarshals body → In, runs the
// handler, and marshals Out → JSON response.
type TypedHandler[In, Out any] func(ctx context.Context, in *In) (*Out, error)
// registeredOp is the bookkeeping zip keeps for OpenAPI generation.
// One op per typed route.
type registeredOp struct {
Method string
Path string
OperationID string
Summary string
Tags []string
InType reflect.Type
OutType reflect.Type
}
// Get registers a GET typed handler at path.
func Get[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "GET", path, fn, opts...)
}
// Post registers a POST typed handler at path.
func Post[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "POST", path, fn, opts...)
}
// Put registers a PUT typed handler at path.
func Put[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "PUT", path, fn, opts...)
}
// Patch registers a PATCH typed handler at path.
func Patch[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "PATCH", path, fn, opts...)
}
// Delete registers a DELETE typed handler at path.
func Delete[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "DELETE", path, fn, opts...)
}
// OpOption configures a typed handler registration (OpenAPI metadata).
type OpOption func(*registeredOp)
// WithSummary sets the operation summary in OpenAPI.
func WithSummary(s string) OpOption { return func(op *registeredOp) { op.Summary = s } }
// WithTags sets the operation tags in OpenAPI.
func WithTags(tags ...string) OpOption { return func(op *registeredOp) { op.Tags = tags } }
// WithOperationID sets the operation ID in OpenAPI.
func WithOperationID(id string) OpOption {
return func(op *registeredOp) { op.OperationID = id }
}
func registerTyped[In, Out any](app *App, method, path string, fn TypedHandler[In, Out], opts ...OpOption) {
var inZero In
var outZero Out
op := &registeredOp{
Method: method,
Path: path,
InType: reflect.TypeOf(inZero),
OutType: reflect.TypeOf(outZero),
}
for _, o := range opts {
o(op)
}
app.ops = append(app.ops, op)
handler := func(c fiber.Ctx) error {
var in In
if method != "GET" && method != "HEAD" {
if len(c.Body()) > 0 {
if err := jsonenc.Unmarshal(c.Body(), &in); err != nil {
return ErrBadRequest("invalid json body: " + err.Error())
}
}
}
// Validate via struct tags.
if err := validate(&in); err != nil {
return ErrBadRequest(err.Error())
}
out, err := fn(c.Context(), &in)
if err != nil {
return err
}
if out == nil {
c.Status(204)
return nil
}
return c.JSON(out)
}
app.fiber.Add([]string{method}, path, handler)
}