feat: standardize transport contracts across Gin and nethttp

This commit is contained in:
2026-09-21 00:10:44 +08:00
parent affd02d5f7
commit 5561795d80
22 changed files with 1137 additions and 205 deletions

View File

@@ -0,0 +1,406 @@
package transporttest
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/app"
"git.yuntuo.hk/k2safe/go-omni/auth"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func application(t *testing.T, newRouter func() httpx.Router) {
t.Run("explicit application methods", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
mark := func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
c.SetResponseHeader("X-Middleware", "present")
return next(c)
}
}
a.Handle("HEAD", "/users", func(c *httpx.Context) error { return c.NoContent() }, mark)
group := a.Group("/api", mark)
group.Handle("OPTIONS", "/users", func(c *httpx.Context) error { return c.NoContent() })
group.Handle("REPORT", "/users", func(c *httpx.Context) error { return c.OK("report") })
for _, tc := range []struct {
method, path string
status int
}{
{"HEAD", "/users", 204}, {"OPTIONS", "/api/users", 204}, {"REPORT", "/api/users", 200},
} {
w := request(a, tc.method, tc.path, "")
if w.Code != tc.status || w.Header().Get("X-Middleware") != "present" || w.Header().Get("X-Request-ID") == "" {
t.Fatalf("%s bypassed the application chain: %d %v", tc.method, w.Code, w.Header())
}
}
})
t.Run("middleware order and registration snapshot", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
var calls []string
mark := func(name string) httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
calls = append(calls, name+" in")
err := next(c)
calls = append(calls, name+" out")
return err
}
}
}
a.Use(mark("app"))
group := a.Group("/api", mark("group"))
nested := group.Group("/v1", mark("nested"))
nested.GET("/users/:id", func(c *httpx.Context) error {
calls = append(calls, "handler")
return c.OK(c.Param("id"))
}, mark("route"))
a.Use(mark("lateApp"))
group.Use(mark("lateGroup"))
request(a, "GET", "/api/v1/users/42", "")
want := "app in,group in,nested in,route in,handler,route out,nested out,group out,app out"
if got := strings.Join(calls, ","); got != want {
t.Fatalf("middleware order=%s", got)
}
})
t.Run("binding and business context", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
var logs bytes.Buffer
logger, err := logx.New(logx.Config{Format: "json", Writer: &logs})
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deadline := time.Now().Add(time.Minute)
a.POST("/users/:id", func(c *httpx.Context) error {
var uri struct {
ID int `uri:"id" binding:"min=1"`
}
var query struct {
PageSize int `form:"pageSize" binding:"min=1"`
}
var body struct {
Name string `json:"name" binding:"required"`
}
if err := c.BindURI(&uri); err != nil {
return err
}
if err := c.BindQuery(&query); err != nil {
return err
}
if err := c.BindJSON(&body); err != nil {
return err
}
// Business services consume standard context only.
result, err := businessResult(c.Context(), logger, deadline, uri.ID, query.PageSize, body.Name)
if err != nil {
return err
}
return c.Created(result)
})
req := httptest.NewRequest("POST", "/users/42?pageSize=20", strings.NewReader(`{"name":"Ada"}`))
ctx, cancel := context.WithDeadline(context.WithValue(req.Context(), contextKey{}, "original"), deadline)
defer cancel()
req = req.WithContext(ctx)
req.Header.Set("X-Request-ID", "businessRequest")
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
if w.Code != 201 || !strings.Contains(w.Body.String(), `"pageSize":20`) || !strings.Contains(w.Body.String(), `"requestId":"businessRequest"`) {
t.Fatalf("unexpected business response: %d %s", w.Code, w.Body)
}
if !strings.Contains(logs.String(), `"requestId":"businessRequest"`) {
t.Fatalf("business log lost request context: %s", &logs)
}
for _, tc := range []struct{ path, body, code string }{
{"/users/bad?pageSize=20", `{"name":"Ada"}`, "INVALID_PATH"},
{"/users/42?pageSize=bad", `{"name":"Ada"}`, "INVALID_QUERY"},
{"/users/42?pageSize=20", "{", "INVALID_BODY"},
{"/users/42?pageSize=20", "{}", "INVALID_BODY"},
} {
w := request(a, "POST", tc.path, tc.body)
assertEnvelope(t, w, 400, tc.code)
}
})
t.Run("authentication short circuit", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
calls := 0
provider := auth.StaticAuthenticator{
"admin": {UserID: "adminUser", Roles: []string{"admin"}},
"reader": {UserID: "readerUser"},
}
a.GET("/private", func(c *httpx.Context) error {
calls++
p, ok := auth.FromContext(c)
if !ok {
return errors.New("missing principal")
}
return c.OK(p.UserID)
}, auth.RequireAuth(provider), auth.RequireRole("admin"))
for _, tc := range []struct {
token string
status int
code string
}{
{"", 401, "UNAUTHENTICATED"}, {"invalid", 401, "UNAUTHENTICATED"},
{"reader", 403, "FORBIDDEN"}, {"admin", 200, "OK"},
} {
req := httptest.NewRequest("GET", "/private", nil)
if tc.token != "" {
req.Header.Set("Authorization", "Bearer "+tc.token)
}
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
assertEnvelope(t, w, tc.status, tc.code)
}
if calls != 1 {
t.Fatalf("unauthorized requests reached business handler: %d calls", calls)
}
})
t.Run("error recovery and committed response", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
for _, tc := range []struct {
path string
handler httpx.Handler
status int
code string
}{
{"/notFound", func(c *httpx.Context) error {
return fmt.Errorf("wrapped: %w", apierrors.NotFound("USER_MISSING", "user missing"))
}, 404, "USER_MISSING"},
{"/internal", func(c *httpx.Context) error { return errors.New("private database password") }, 500, "INTERNAL_ERROR"},
{"/panic", func(c *httpx.Context) error { panic("private database password") }, 500, "INTERNAL_ERROR"},
{"/encode", func(c *httpx.Context) error { return c.OK(make(chan int)) }, 500, "INTERNAL_ERROR"},
{"/written", func(c *httpx.Context) error {
if err := c.Created("saved"); err != nil {
return err
}
return errors.New("private database password")
}, 201, "CREATED"},
{"/panicAfterWrite", func(c *httpx.Context) error { _ = c.Accepted("saved"); panic("private database password") }, 202, "ACCEPTED"},
{"/doubleWrite", func(c *httpx.Context) error { _ = c.OK("first"); return c.Created("second") }, 200, "OK"},
} {
a.GET(tc.path, tc.handler)
w := request(a, "GET", tc.path, "")
assertEnvelope(t, w, tc.status, tc.code)
if strings.Contains(w.Body.String(), "private") {
t.Fatalf("internal cause leaked: %s", w.Body)
}
}
a.DELETE("/deleted", func(c *httpx.Context) error {
_ = c.NoContent()
return errors.New("after response")
})
w := request(a, "DELETE", "/deleted", "")
if w.Code != 204 || w.Body.Len() != 0 {
t.Fatalf("204 response=%d %s", w.Code, w.Body)
}
})
t.Run("concurrent request isolation", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/users/:id", func(c *httpx.Context) error {
c.Set("local", c.Param("id"))
value, _ := c.Get("local")
return c.OK(value)
})
var workers sync.WaitGroup
for i := 0; i < 32; i++ {
workers.Add(1)
go func(i int) {
defer workers.Done()
id := fmt.Sprint(i)
req := httptest.NewRequest("GET", "/users/"+id, nil)
req.Header.Set("X-Request-ID", "request"+id)
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
var body struct {
Data string
RequestID string `json:"requestId"`
}
err := json.Unmarshal(w.Body.Bytes(), &body)
if err != nil || body.Data != id || body.RequestID != "request"+id || w.Header().Get("X-Request-ID") != "request"+id {
t.Errorf("request %s leaked state: %s, error=%v", id, w.Body, err)
}
}(i)
}
workers.Wait()
})
t.Run("middleware panic", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/panic", func(c *httpx.Context) error {
t.Error("handler reached after middleware panic")
return nil
}, func(httpx.Handler) httpx.Handler {
return func(*httpx.Context) error { panic("private middleware state") }
})
assertEnvelope(t, request(a, "GET", "/panic", ""), 500, "INTERNAL_ERROR")
})
t.Run("write failure preserves committed response", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/write", func(c *httpx.Context) error { return c.Created("value") })
w := &failedWriter{ResponseRecorder: httptest.NewRecorder()}
a.ServeHTTP(w, httptest.NewRequest("GET", "/write", nil))
if w.Code != 201 || w.headers != 1 || w.writes != 1 {
t.Fatalf("error response appended after failed write: status=%d headers=%d writes=%d", w.Code, w.headers, w.writes)
}
})
t.Run("expired deadline reaches service", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.GET("/deadline", func(c *httpx.Context) error {
if !errors.Is(c.Context().Err(), context.DeadlineExceeded) {
t.Errorf("deadline lost: %v", c.Context().Err())
}
select {
case <-c.Context().Done():
default:
t.Error("expired request context was replaced")
}
return c.Context().Err()
})
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
req := httptest.NewRequest("GET", "/deadline", nil).WithContext(ctx)
w := httptest.NewRecorder()
a.ServeHTTP(w, req)
assertEnvelope(t, w, 500, "INTERNAL_ERROR")
})
t.Run("client cancellation reaches service", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
started := make(chan struct{})
done := make(chan error, 1)
stop := make(chan struct{})
a.GET("/wait/:id", func(c *httpx.Context) error {
close(started)
err := waitService(c.Context(), stop)
done <- err
return err
})
server := httptest.NewServer(a)
ctx, cancel := context.WithCancel(context.Background())
defer func() { cancel(); close(stop); server.Close() }()
clientDone := make(chan error, 1)
req, _ := http.NewRequestWithContext(ctx, "GET", server.URL+"/wait/42", nil)
go func() {
resp, err := server.Client().Do(req)
if resp != nil {
resp.Body.Close()
}
clientDone <- err
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("handler did not start")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("service cancellation=%v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("client cancellation did not reach service")
}
select {
case err := <-clientDone:
if !errors.Is(err, context.Canceled) {
t.Fatalf("client cancellation=%v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("client did not return")
}
})
t.Run("HEAD over a real connection", func(t *testing.T) {
a := app.NewWithRouter(newRouter())
a.Any("/head", func(c *httpx.Context) error { return c.OK("body") })
server := httptest.NewServer(a)
defer server.Close()
req, _ := http.NewRequest("HEAD", server.URL+"/head", nil)
client := server.Client()
client.Timeout = 5 * time.Second
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != 200 || len(body) != 0 || resp.Header.Get("X-Request-ID") == "" {
t.Fatalf("HEAD status=%d body=%q error=%v", resp.StatusCode, body, err)
}
})
}
func businessResult(ctx context.Context, logger *logx.Logger, deadline time.Time, id, size int, name string) (any, error) {
got, ok := ctx.Deadline()
if !ok || !got.Equal(deadline) || ctx.Value(contextKey{}) != "original" || logx.RequestID(ctx) != "businessRequest" {
return nil, errors.New("service lost request context")
}
logger.InfoContext(ctx, "business.read", "userId", id)
return map[string]any{"userId": id, "pageSize": size, "name": name}, nil
}
func waitService(ctx context.Context, stop <-chan struct{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-stop:
return errors.New("test cleanup")
}
}
func request(handler http.Handler, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func assertEnvelope(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
t.Helper()
var body struct {
Code string
RequestID string `json:"requestId"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid response JSON: %s: %v", w.Body, err)
}
if w.Code != status || body.Code != code || body.RequestID == "" || body.RequestID != w.Header().Get("X-Request-ID") {
t.Fatalf("response=%d %s; want %d %s with requestId", w.Code, w.Body, status, code)
}
}
type failedWriter struct {
*httptest.ResponseRecorder
headers, writes int
}
func (w *failedWriter) WriteHeader(status int) {
w.headers++
w.ResponseRecorder.WriteHeader(status)
}
func (w *failedWriter) Write([]byte) (int, error) {
w.writes++
return 0, io.ErrClosedPipe
}

View File

@@ -0,0 +1,198 @@
// Package transporttest provides the reusable acceptance suite for GoOmni
// router adapters. Import it only from tests and pass a fresh router factory.
// Register routes before serving; the suite exercises concurrent requests.
package transporttest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
// Run verifies routing and the same business handlers on a supplied adapter.
// Factories are called serially and must return a fresh, empty router.
func Run(t *testing.T, newRouter func() httpx.Router) {
t.Helper()
t.Run("routing", func(t *testing.T) { routing(t, newRouter) })
t.Run("registration", func(t *testing.T) { registration(t, newRouter) })
t.Run("application", func(t *testing.T) { application(t, newRouter) })
}
type contextKey struct{}
type routeResult struct {
Pattern string `json:"pattern"`
Params map[string]string `json:"params"`
Value string `json:"value"`
}
func routing(t *testing.T, newRouter func() httpx.Router) {
r := newRouter()
echo := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
value, _ := req.Context().Value(contextKey{}).(string)
_ = json.NewEncoder(w).Encode(routeResult{
Pattern: httpx.RoutePattern(req.Context()), Params: httpx.RouteParams(req.Context()), Value: value,
})
})
for _, path := range []string{
"/", "/users/:id", "/users/me", "/files/*path", "/slash/",
"/choice/:first/fixed/final", "/choice/static/:second/:third",
"/back/static", "/back/:id/details",
} {
r.Handle(http.MethodGet, path, echo)
}
// Registration order must not determine static-vs-parameter precedence.
r.Handle(http.MethodPost, "/users/me", echo)
r.Handle(http.MethodPost, "/users/:id", echo)
r.Handle(http.MethodHead, "/head", echo)
r.Handle(http.MethodOptions, "/head", echo)
r.Handle("REPORT", "/report", echo)
group := r.Group("/api/").Group("v1")
group.Handle(http.MethodGet, "/users/:id", echo)
group.Handle(http.MethodGet, "", echo)
group.Handle(http.MethodGet, "/", echo)
group.Any("/any", echo)
r.Any("/any", echo)
cases := []struct {
method, path, pattern, param, value string
status int
allow string
}{
{"GET", "/", "/", "", "", 200, ""},
{"GET", "/users/42?x=y", "/users/:id", "id", "42", 200, ""},
{"GET", "/users/me", "/users/me", "", "", 200, ""},
{"POST", "/users/me", "/users/me", "", "", 200, ""},
{"GET", "/users/%E4%B8%AD%E6%96%87", "/users/:id", "id", "中文", 200, ""},
{"GET", "/users/a%2Fb", "", "", "", 404, ""},
{"GET", "/users/a+b", "/users/:id", "id", "a+b", 200, ""},
{"GET", "/users/", "", "", "", 404, ""},
{"GET", "/users/42/", "", "", "", 404, ""},
{"GET", "/users//42", "", "", "", 404, ""},
{"GET", "/Users/42", "", "", "", 404, ""},
{"GET", "/slash", "", "", "", 404, ""},
{"GET", "/slash/", "/slash/", "", "", 200, ""},
{"GET", "/files", "", "", "", 404, ""},
{"GET", "/files/", "/files/*path", "path", "/", 200, ""},
{"GET", "/files/a%2Fb/c", "/files/*path", "path", "/a/b/c", 200, ""},
{"GET", "/files//a", "/files/*path", "path", "//a", 200, ""},
{"GET", "/choice/static/fixed/final", "/choice/static/:second/:third", "second", "fixed", 200, ""},
{"GET", "/back/static/details", "/back/:id/details", "id", "static", 200, ""},
{"GET", "/api/v1/users/42", "/api/v1/users/:id", "id", "42", 200, ""},
{"GET", "/api/v1", "/api/v1", "", "", 200, ""},
{"GET", "/api/v1/", "/api/v1/", "", "", 200, ""},
{"REPORT", "/report", "/report", "", "", 200, ""},
{"HEAD", "/head", "/head", "", "", 200, ""},
{"OPTIONS", "/head", "/head", "", "", 200, ""},
{"HEAD", "/users/42", "", "", "", 405, "GET, POST"},
{"OPTIONS", "/users/42", "", "", "", 405, "GET, POST"},
{"DELETE", "/users/me", "", "", "", 405, "GET, POST"},
{"GET", "/head", "", "", "", 405, "HEAD, OPTIONS"},
{"GET", "/missing", "", "", "", 404, ""},
{"REPORT", "/any", "", "", "", 405, "CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE"},
}
for _, method := range []string{"GET", "POST", "PUT", "PATCH", "HEAD", "OPTIONS", "DELETE", "CONNECT", "TRACE"} {
for _, path := range []string{"/any", "/api/v1/any"} {
cases = append(cases, struct {
method, path, pattern, param, value string
status int
allow string
}{method, path, path, "", "", 200, ""})
}
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
// CONNECT has authority-form handling in net/http's parser; the
// adapter contract consumes an already parsed URL.Path.
if tc.method == "CONNECT" {
req.URL.Path = tc.path
}
req = req.WithContext(context.WithValue(req.Context(), contextKey{}, "preserved"))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tc.status || w.Header().Get("Allow") != tc.allow || w.Header().Get("Location") != "" {
t.Fatalf("status=%d allow=%q location=%q body=%s", w.Code, w.Header().Get("Allow"), w.Header().Get("Location"), w.Body)
}
if tc.status != 200 {
want := "404 page not found\n"
if tc.status == 405 {
want = "Method Not Allowed\n"
}
if w.Body.String() != want || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("unexpected fallback response: %s", w.Body)
}
return
}
var got routeResult
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
wantParams := map[string]string{}
if tc.param != "" {
wantParams[tc.param] = tc.value
}
// Routes with several parameters are checked in full.
if tc.pattern == "/choice/static/:second/:third" {
wantParams["third"] = "final"
}
if got.Pattern != tc.pattern || !reflect.DeepEqual(got.Params, wantParams) || got.Value != "preserved" {
t.Fatalf("route metadata=%+v, want %s %v with original context", got, tc.pattern, wantParams)
}
})
}
}
func registration(t *testing.T, newRouter func() httpx.Router) {
handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
cases := []struct {
name string
register func(httpx.Router)
}{
{"nil handler", func(r httpx.Router) { r.Handle("GET", "/", nil) }},
{"empty method", func(r httpx.Router) { r.Handle("", "/", handler) }},
{"lowercase method", func(r httpx.Router) { r.Handle("get", "/", handler) }},
}
for _, path := range []string{"", "relative", "/:", "/*", "/a/*rest/b", "/a/:id/:id", "/a//b", "/a/../b", "/a?b", "/prefix:id", "/:a:b"} {
cases = append(cases, struct {
name string
register func(httpx.Router)
}{"invalid " + path, func(r httpx.Router) { r.Handle("GET", path, handler) }})
}
for _, pair := range [][2]string{
{"/users", "/users"}, {"/users/:id", "/users/:name/more"},
{"/files/*path", "/files/static"}, {"/files/static", "/files/*path"},
{"/", "/*path"}, {"/*path", "/"},
} {
cases = append(cases, struct {
name string
register func(httpx.Router)
}{"conflict " + pair[0] + " " + pair[1], func(r httpx.Router) {
r.Handle("GET", pair[0], handler)
r.Handle("GET", pair[1], handler)
}})
}
cases = append(cases, struct {
name string
register func(httpx.Router)
}{"Any conflicts with explicit method", func(r httpx.Router) {
r.Any("/any", handler)
r.Handle("GET", "/any", handler)
}})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("registration must panic")
}
}()
tc.register(newRouter())
})
}
}