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,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())
})
}
}