feat: standardize transport contracts across Gin and nethttp
This commit is contained in:
406
transport/transporttest/application.go
Normal file
406
transport/transporttest/application.go
Normal 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
|
||||
}
|
||||
198
transport/transporttest/router.go
Normal file
198
transport/transporttest/router.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user