Files
zip/openapi.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

276 lines
7.0 KiB
Go

package zip
import (
"reflect"
"sort"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/hanzoai/zip/internal/jsonenc"
)
// OpenAPIConfig configures the auto-generated /.well-known/openapi.json
// endpoint zip serves when typed handlers are registered.
type OpenAPIConfig struct {
// Title appears in the OpenAPI info block.
Title string
// Description appears in the OpenAPI info block.
Description string
// Version appears in the OpenAPI info block (e.g. "v1.0.0").
Version string
// Disabled suppresses the /.well-known/openapi.json route and /docs.
Disabled bool
}
// installOpenAPIRoutes wires /.well-known/openapi.json and /docs.
// Called from Listen / Serve. Idempotent if there are no typed ops.
func (a *App) installOpenAPIRoutes() {
if a.cfg.OpenAPI.Disabled || len(a.ops) == 0 {
return
}
spec := a.buildOpenAPI()
specJSON, _ := jsonenc.Marshal(spec)
a.fiber.Get("/.well-known/openapi.json", func(fc fiber.Ctx) error {
fc.Set("Content-Type", "application/json")
return fc.Send(specJSON)
})
a.fiber.Get("/docs", func(fc fiber.Ctx) error {
fc.Set("Content-Type", "text/html; charset=utf-8")
return fc.SendString(swaggerHTML)
})
}
// buildOpenAPI walks the registered typed ops and builds an OpenAPI 3.1
// spec as a plain map (json.Marshal serializes anything map-shaped).
func (a *App) buildOpenAPI() map[string]any {
cfg := a.cfg.OpenAPI
if cfg.Title == "" {
cfg.Title = a.cfg.AppName
}
if cfg.Title == "" {
cfg.Title = "zip API"
}
if cfg.Version == "" {
cfg.Version = "0.0.0"
}
paths := map[string]map[string]any{}
schemas := map[string]any{}
// Sort ops by path,method for deterministic output.
ops := append([]*registeredOp{}, a.ops...)
sort.Slice(ops, func(i, j int) bool {
if ops[i].Path != ops[j].Path {
return ops[i].Path < ops[j].Path
}
return ops[i].Method < ops[j].Method
})
for _, op := range ops {
path := op.Path
// OpenAPI uses {name} for path params; fiber uses :name. Translate.
path = strings.ReplaceAll(path, "/:", "/{")
if strings.Contains(path, "{") && !strings.Contains(path, "}") {
// Was ":name" -> "{name" needs the closing brace.
path = closeColonParams(op.Path)
}
if _, ok := paths[path]; !ok {
paths[path] = map[string]any{}
}
opObj := map[string]any{
"operationId": op.OperationID,
"summary": op.Summary,
}
if op.OperationID == "" {
opObj["operationId"] = defaultOpID(op.Method, op.Path)
}
if len(op.Tags) > 0 {
opObj["tags"] = op.Tags
}
// Request body.
if op.Method != "GET" && op.Method != "HEAD" && op.Method != "DELETE" {
inName := typeName(op.InType)
if op.InType != nil && inName != "" {
schemas[inName] = schemaOf(op.InType, schemas)
opObj["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/" + inName},
},
},
}
}
}
// 200 response.
outName := typeName(op.OutType)
if op.OutType != nil && outName != "" {
schemas[outName] = schemaOf(op.OutType, schemas)
opObj["responses"] = map[string]any{
"200": map[string]any{
"description": "ok",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/" + outName},
},
},
},
}
} else {
opObj["responses"] = map[string]any{
"204": map[string]any{"description": "no content"},
}
}
paths[path][strings.ToLower(op.Method)] = opObj
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": cfg.Title,
"description": cfg.Description,
"version": cfg.Version,
},
"paths": paths,
"components": map[string]any{
"schemas": schemas,
},
}
}
func defaultOpID(method, path string) string {
clean := strings.ReplaceAll(path, "/", "_")
clean = strings.ReplaceAll(clean, "{", "")
clean = strings.ReplaceAll(clean, "}", "")
clean = strings.ReplaceAll(clean, ":", "")
return strings.ToLower(method) + clean
}
func closeColonParams(path string) string {
// Convert ":name" segments to "{name}".
parts := strings.Split(path, "/")
for i, p := range parts {
if strings.HasPrefix(p, ":") {
parts[i] = "{" + p[1:] + "}"
}
}
return strings.Join(parts, "/")
}
func typeName(t reflect.Type) string {
if t == nil {
return ""
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Name() == "" {
return ""
}
return t.Name()
}
// schemaOf builds a minimal JSON Schema for t. Handles structs, primitives,
// slices, and pointers. Anonymous types become "object" without a ref.
func schemaOf(t reflect.Type, registry map[string]any) map[string]any {
if t == nil {
return map[string]any{"type": "object"}
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t.Kind() {
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number"}
case reflect.Slice, reflect.Array:
return map[string]any{
"type": "array",
"items": schemaOf(t.Elem(), registry),
}
case reflect.Map:
return map[string]any{
"type": "object",
"additionalProperties": schemaOf(t.Elem(), registry),
}
case reflect.Struct:
props := map[string]any{}
required := []string{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
name := jsonFieldName(f)
if name == "-" {
continue
}
props[name] = schemaOf(f.Type, registry)
if tag := f.Tag.Get("validate"); strings.Contains(tag, "required") {
required = append(required, name)
}
}
out := map[string]any{
"type": "object",
"properties": props,
}
if len(required) > 0 {
out["required"] = required
}
return out
}
return map[string]any{"type": "object"}
}
func jsonFieldName(f reflect.StructField) string {
tag := f.Tag.Get("json")
if tag == "" {
return f.Name
}
if i := strings.IndexByte(tag, ','); i >= 0 {
tag = tag[:i]
}
if tag == "" {
return f.Name
}
return tag
}
// swaggerHTML is the minimal Swagger UI shell. Loads the UI from a CDN
// and points it at /.well-known/openapi.json. About 30 lines — no bundled
// JS in the binary.
const swaggerHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>API Docs</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "/.well-known/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
});
};
</script>
</body>
</html>
`