mirror of
https://github.com/hanzoai/agents.git
synced 2026-10-30 18:48:36 +00:00
Move the control-plane's REST/UI surface off gin's router and onto
github.com/zap-proto/zip v1.10.0 (over zap-proto/http v0.3.0). zip now owns
routing, the middleware chain and the transport; all 130 routes are registered
natively via App.Get/Post/Put/Patch/Delete and App.Group.
The route table is unchanged: 130 method+path pairs before and after, verified
by diff. The only spelling change is the wildcard leaf, "/ui/*filepath" ->
"/ui/*", which matches the same paths.
Handlers are fronted rather than rewritten. ~24k lines of gin handler bodies
are this service's public API and a hand port of that volume cannot be proven
equivalent, so internal/server/ginbridge.go provides the one seam:
ginHandler(h) wraps a gin.HandlerFunc, copying the matched zip route's
params onto the gin context so c.Param(...) still works.
ginChain(mws...) runs the gin middleware stack (CORS, access log, timeout,
API-key auth) unmodified, so CORS origin echoing and the
auth abort bodies stay byte-for-byte identical.
The net/http adaptor underneath supports buffered, flushed and hijacked
responses, so the SSE and WebSocket routes keep streaming through the same
seam rather than being rewritten badly.
Two things that would otherwise silently break the API:
- Start listens on "http://:PORT". A bare address binds zip's default ZAP
transport, which would change the wire protocol of the REST surface.
- The NoRoute fallback is All("/+"), not All("/*"). "/*" also matches "/"
and shadowed the root redirect, turning it into a 404.
Coverage: routes_test.go pins the exact 130-route table and the NoRoute
semantics; ginbridge_test.go pins params, status, body, CORS echo, auth abort
and SSE through the bridge. Both pass under the default and "embedded" build
tags. A package TestMain sets gin's mode once, removing the pre-existing
gin.SetMode data race between parallel tests in this package.
Co-authored-by: Hanzo Dev <[email protected]>
114 lines
3.4 KiB
Go
114 lines
3.4 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-contrib/cors"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/zap-proto/fiber/v3"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// TestGinBridge pins the seam between zip's router and the service's gin
|
|
// handlers: path params, status codes, response bodies, the gin middleware
|
|
// chain (CORS origin echo + auth abort) and SSE streaming must all survive it
|
|
// unchanged. These are the properties the whole migration rests on.
|
|
func TestGinBridge(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
|
|
app.Use(ginChain(
|
|
cors.New(cors.Config{
|
|
AllowOrigins: []string{"http://localhost:3000", "http://localhost:5173"},
|
|
AllowMethods: []string{"GET", "POST"},
|
|
AllowHeaders: []string{"Origin", "X-API-Key"},
|
|
}),
|
|
func(c *gin.Context) {
|
|
if c.Request.URL.Path == "/secret" && c.GetHeader("X-API-Key") != "k" {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
c.Next()
|
|
},
|
|
))
|
|
|
|
app.Get("/nodes/:node_id/status", ginHandler(func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"id": c.Param("node_id")})
|
|
}))
|
|
app.Get("/secret", ginHandler(func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}))
|
|
app.Get("/teapot", ginHandler(func(c *gin.Context) {
|
|
c.JSON(http.StatusTeapot, gin.H{"err": "nope"})
|
|
}))
|
|
app.Get("/stream", ginHandler(func(c *gin.Context) {
|
|
c.Header("Content-Type", "text/event-stream")
|
|
f, _ := c.Writer.(http.Flusher)
|
|
for i := 0; i < 3; i++ {
|
|
fmt.Fprintf(c.Writer, "data: e%d\n\n", i)
|
|
f.Flush()
|
|
}
|
|
}))
|
|
|
|
do := func(method, path string, hdr map[string]string) (*http.Response, string) {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
for k, v := range hdr {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := app.Fiber().Test(req, fiber.TestConfig{Timeout: 10 * time.Second, FailOnTimeout: true})
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp, string(b)
|
|
}
|
|
|
|
t.Run("param+body", func(t *testing.T) {
|
|
resp, body := do("GET", "/nodes/abc123/status", nil)
|
|
if resp.StatusCode != 200 || body != `{"id":"abc123"}` {
|
|
t.Fatalf("got %d %q", resp.StatusCode, body)
|
|
}
|
|
})
|
|
|
|
t.Run("status preserved", func(t *testing.T) {
|
|
resp, body := do("GET", "/teapot", nil)
|
|
if resp.StatusCode != http.StatusTeapot || body != `{"err":"nope"}` {
|
|
t.Fatalf("got %d %q", resp.StatusCode, body)
|
|
}
|
|
})
|
|
|
|
t.Run("cors echoes origin", func(t *testing.T) {
|
|
resp, _ := do("GET", "/teapot", map[string]string{"Origin": "http://localhost:5173"})
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
|
|
t.Fatalf("ACAO = %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("auth abort", func(t *testing.T) {
|
|
resp, body := do("GET", "/secret", nil)
|
|
if resp.StatusCode != 401 || body != `{"error":"unauthorized"}` {
|
|
t.Fatalf("got %d %q", resp.StatusCode, body)
|
|
}
|
|
resp, body = do("GET", "/secret", map[string]string{"X-API-Key": "k"})
|
|
if resp.StatusCode != 200 || body != `{"ok":true}` {
|
|
t.Fatalf("authed: got %d %q", resp.StatusCode, body)
|
|
}
|
|
})
|
|
|
|
t.Run("sse streams", func(t *testing.T) {
|
|
resp, body := do("GET", "/stream", nil)
|
|
if resp.StatusCode != 200 || body != "data: e0\n\ndata: e1\n\ndata: e2\n\n" {
|
|
t.Fatalf("got %d %q", resp.StatusCode, body)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
|
t.Fatalf("content-type = %q", ct)
|
|
}
|
|
})
|
|
}
|