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

@@ -1,22 +1,77 @@
# Transport adapters
GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。
使用标准库适配器:
业务 Handler 依赖 GoOmni 的 `httpx.Context`,Service 接收标准 `context.Context`。适配器只在启动入口组装:
```go
import (
omni "git.yuntuo.hk/k2safe/go-omni"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
omni "git.yuntuo.hk/k2safe/go-omni"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
application := omni.NewWithRouter(netrouter.New())
registerRoutes(application) // 与 omni.New() 复用同一份业务注册代码
```
后续自研网络层时,实现以下能力即可接入现有 `app`、中间件和业务 Handler:
`transport/gin` 默认使用 Gin 分发请求;`transport/nethttp` 使用独立的标准库路由实现。两者共用路由声明校验和路径匹配规则,Gin 的正常请求仍由 Gin 路由树处理;它的 404/405 回退由适配层统一处理。
- `httpx.Router`:注册路由、分组并实现 `http.Handler`。
- `httpx.RouterGroup`:注册分组内路由。
- 适配器为请求 context 注入 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern`。
## 可移植路由规则(v0.3.0)
业务层不应该导入具体 transport 包;它们只是网络层实现。
| 项目 | 约定 |
| --- | --- |
| 路径 | 顶层路由必须以 `/` 开头;区分大小写。禁止重复斜杠、`.` / `..` 段、查询串和片段标识。 |
| 参数 | `/users/:id` 匹配一个非空路径段;参数必须占据整个段,同一路由内名称唯一。 |
| 通配 | `/files/*path` 仅允许出现在末尾,值包含开头的 `/`;`/files/` 对应 `/`,`/files` 不匹配。 |
| 路由优先级 | 相同方法内,从左向右比较,静态段优先于参数段;更具体的分支匹配失败后允许回退。 |
| 注册冲突 | 同方法重复注册、相同前缀处参数名冲突、catch-all 与同级分支冲突,均在注册时 panic。不同方法可用同一路径。 |
| 分组 | 支持嵌套与相对路径。`Group("/api").Handle("GET", "", h)` 注册 `/api`,传 `"/"` 则注册 `/api/`。 |
| 尾部斜杠 | `/users` 与 `/users/` 是不同路由;不自动重定向、补斜杠或清理请求路径。 |
| URL 编码 | 使用已解码的 `URL.Path`;例如 `%2F` 作为路径分隔符,`+` 保留为加号。 |
| 方法 | `Handle` 接受大写字母组成的方法名;HEAD 不自动复用 GET,OPTIONS 不自动成功。 |
| Any | 注册 GET、POST、PUT、PATCH、HEAD、OPTIONS、DELETE、CONNECT、TRACE 九种方法;其他方法需显式注册。 |
| 404 / 405 | 未找到路径返回纯文本 404;路径存在但方法不支持时返回纯文本 405,`Allow` 去重并按字母排序。 |
| 并发 | 启动前完成路由注册;允许并发处理请求,各请求参数、业务值、requestId 必须隔离。 |
HTTP 回退响应属于路由层,不进入应用路由中间件,不携带统一 JSON 封装或 requestId。业务 Handler 返回的错误则经过统一 JSON 错误处理。
业务层通过 `application.Handle("HEAD", path, handler)` 或 `group.Handle("OPTIONS", path, handler)` 显式注册其他方法,同样经过框架中间件和错误处理。
`gin.NewWithEngine` 保留原生路由和中间件,但覆盖重定向、路径清理和 404/405 策略。可移植契约与 `Allow` 集合只覆盖经 GoOmni Router 注册的路由;直接操作 `Engine()` 的原生扩展不属于该保证。
## 业务行为
- 同一套 Handler、Service、中间件用于两种适配器;应用、分组、嵌套分组、路由中间件依次进入、反序退出。
- `Use` 只影响后续注册的路由。鉴权失败短路处理链,panic 转为统一 500。
- URI / Query / JSON 绑定、业务错误、requestId 与业务日志关联一致。
- `JSON` / `OK` 等方法先编码再发送;编码失败返回统一 500。已通过 Context 提交响应后,后续错误、panic 或重复响应不会追加 JSON。
- 客户端断开会取消传入 Service 的 context;适配时保留原有 context 的值、截止时间和取消信号。
- `Raw()` 是高级逃生口;直接写入、流式响应、Hijack 等行为不属于上述 Context 响应追踪契约,勿与自动响应方法混用。
## 自研适配器验收
实现 `httpx.Router` / `httpx.RouterGroup`,在调用 Handler 前通过 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern` 注入请求元数据。使用外部测试包接入公共验收套件:
```go
package myrouter_test
import (
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/transport/transporttest"
"example.com/project/myrouter"
)
func TestContract(t *testing.T) {
transporttest.Run(t, func() httpx.Router { return myrouter.New() })
}
```
工厂需每次返回新的空路由器。套件依次创建路由器,内部会发送并发请求、打开本地 HTTP 测试服务,运行时应启用 race 检查:
```sh
go test -race ./transport/... ./examples/reference
python3 scripts/verifyTransports.py --version v0.3.0
```
第一条同时验证完整用户 CRUD 在两种适配器上复用相同 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包。
当前替换边界是路由与 HTTP 适配层,`App.Run` 仍使用标准库 `net/http.Server`。未来自研监听、连接管理或协议解析时,可让自研服务调用 `App.ServeHTTP`(提供标准 HTTP 请求/响应桥接),由自研启动层管理监听与关闭,并在退出时调用 `App.Close`;本阶段没有实现新的网络内核。

View File

@@ -0,0 +1,22 @@
package transport_test
import (
"testing"
"git.yuntuo.hk/k2safe/go-omni/httpx"
ginrouter "git.yuntuo.hk/k2safe/go-omni/transport/gin"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
"git.yuntuo.hk/k2safe/go-omni/transport/transporttest"
)
func TestAdapterContract(t *testing.T) {
for _, adapter := range []struct {
name string
newRouter func() httpx.Router
}{
{"gin", func() httpx.Router { return ginrouter.New("test") }},
{"nethttp", func() httpx.Router { return netrouter.New() }},
} {
t.Run(adapter.name, func(t *testing.T) { transporttest.Run(t, adapter.newRouter) })
}
}

View File

@@ -4,14 +4,23 @@ package gin
import (
"net/http"
"sort"
"strings"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/transport/internal/routing"
ginlib "github.com/gin-gonic/gin"
)
// Router is the default Gin-backed GoOmni router.
type Router struct {
engine *ginlib.Engine
routes []registeredRoute
}
type registeredRoute struct {
method string
segments []routing.Segment
}
// New creates a Gin router without Gin's default logger or recovery middleware.
@@ -19,11 +28,47 @@ func New(mode string) *Router {
if mode != "" {
ginlib.SetMode(mode)
}
return &Router{engine: ginlib.New()}
return NewWithEngine(ginlib.New())
}
// NewWithEngine wraps an existing Gin engine for incremental migration.
func NewWithEngine(engine *ginlib.Engine) *Router { return &Router{engine: engine} }
// NewWithEngine wraps an existing engine and applies GoOmni's routing policy.
// Native middleware/routes are retained; redirect and fallback settings are
// replaced. Register portable routes through Router, not Engine().
func NewWithEngine(engine *ginlib.Engine) *Router {
if engine == nil {
panic("go-omni/gin: nil engine")
}
engine.RedirectTrailingSlash = false
engine.RedirectFixedPath = false
engine.RemoveExtraSlash = false
engine.UseRawPath = false
// Gin 1.10 can carry backtracking state between method trees during its
// 405 lookup. Derive Allow from registered patterns instead of that lookup.
engine.HandleMethodNotAllowed = false
router := &Router{engine: engine}
engine.NoRoute(router.fallback)
return router
}
func (r *Router) fallback(ctx *ginlib.Context) {
allowed := make(map[string]bool)
for _, route := range r.routes {
if _, matches := routing.Match(route.segments, ctx.Request.URL.Path); matches {
allowed[route.method] = true
}
}
if len(allowed) == 0 {
http.NotFound(ctx.Writer, ctx.Request)
return
}
methods := make([]string, 0, len(allowed))
for method := range allowed {
methods = append(methods, method)
}
sort.Strings(methods)
ctx.Header("Allow", strings.Join(methods, ", "))
http.Error(ctx.Writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
// Engine exposes the underlying Gin engine to adapter-specific integrations.
func (r *Router) Engine() *ginlib.Engine { return r.engine }
@@ -33,29 +78,38 @@ func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
}
func (r *Router) Handle(method, path string, handler http.Handler) {
r.engine.Handle(method, path, r.adapt(handler))
segments := routing.Parse(method, path, handler)
r.engine.Handle(method, path, adapt(handler))
r.routes = append(r.routes, registeredRoute{method: method, segments: segments})
}
func (r *Router) Any(path string, handler http.Handler) { r.engine.Any(path, r.adapt(handler)) }
func (r *Router) Any(path string, handler http.Handler) {
for _, method := range routing.Methods {
r.Handle(method, path, handler)
}
}
func (r *Router) Group(path string) httpx.RouterGroup {
return &group{group: r.engine.Group(path)}
return &group{router: r, prefix: routing.Join("", path)}
}
type group struct{ group *ginlib.RouterGroup }
type group struct {
router *Router
prefix string
}
func (g *group) Handle(method, path string, handler http.Handler) {
g.group.Handle(method, path, adapt(handler))
g.router.Handle(method, routing.Join(g.prefix, path), handler)
}
func (g *group) Any(path string, handler http.Handler) { g.group.Any(path, adapt(handler)) }
func (g *group) Any(path string, handler http.Handler) {
g.router.Any(routing.Join(g.prefix, path), handler)
}
func (g *group) Group(path string) httpx.RouterGroup {
return &group{group: g.group.Group(path)}
return &group{router: g.router, prefix: routing.Join(g.prefix, path)}
}
func (r *Router) adapt(handler http.Handler) ginlib.HandlerFunc { return adapt(handler) }
func adapt(handler http.Handler) ginlib.HandlerFunc {
return func(ctx *ginlib.Context) {
params := make(map[string]string, len(ctx.Params))

View File

@@ -0,0 +1,119 @@
// Package routing defines registration rules shared by the built-in adapters.
// Request dispatch remains the responsibility of each adapter.
package routing
import (
"net/http"
"strings"
)
var Methods = [...]string{
http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodHead, http.MethodOptions, http.MethodDelete, http.MethodConnect, http.MethodTrace,
}
type Segment struct {
Literal string
Name string
Wildcard bool
}
// Parse accepts absolute paths, whole-segment parameters and a terminal
// catch-all. It rejects ambiguous patterns before an adapter registers them.
func Parse(method, path string, handler http.Handler) []Segment {
if handler == nil {
panic("go-omni: nil route handler")
}
if method == "" || strings.IndexFunc(method, func(r rune) bool { return r < 'A' || r > 'Z' }) >= 0 {
panic("go-omni: route method must contain uppercase letters")
}
if path == "" || path[0] != '/' {
panic("go-omni: route path must start with /")
}
parts := strings.Split(path[1:], "/")
segments := make([]Segment, len(parts))
names := make(map[string]bool)
for i, part := range parts {
if part == "." || part == ".." || strings.ContainsAny(part, "?#") || (part == "" && i != len(parts)-1) {
panic("go-omni: invalid route path " + path)
}
if strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*") {
name := part[1:]
if name == "" || strings.ContainsAny(name, ":*") || names[name] {
panic("go-omni: route parameters must have unique, non-empty names")
}
wildcard := part[0] == '*'
if wildcard && i != len(parts)-1 {
panic("go-omni: catch-all must be the final segment")
}
names[name] = true
segments[i] = Segment{Name: name, Wildcard: wildcard}
} else {
if strings.ContainsAny(part, ":*") {
panic("go-omni: parameters must occupy a whole path segment")
}
segments[i] = Segment{Literal: part}
}
}
return segments
}
// CheckConflict enforces Gin-compatible ambiguity rules for one method.
func CheckConflict(existing, next []Segment) {
for i := 0; i < len(existing) && i < len(next); i++ {
a, b := existing[i], next[i]
if a.Wildcard || b.Wildcard {
panic("go-omni: catch-all conflicts with an existing route")
}
if a.Name != "" && b.Name != "" {
if a.Name != b.Name {
panic("go-omni: conflicting parameter names at the same route prefix")
}
continue
}
if a != b {
return
}
}
if len(existing) == len(next) {
panic("go-omni: duplicate route")
}
}
// Join supports relative group paths while preserving a final slash.
func Join(prefix, path string) string {
if path == "" {
if prefix == "" {
return "/"
}
return prefix
}
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
}
// Match uses the decoded URL.Path without slash normalization or redirects.
func Match(segments []Segment, path string) (map[string]string, bool) {
if !strings.HasPrefix(path, "/") {
return nil, false
}
parts := strings.Split(path[1:], "/")
params := make(map[string]string)
for i, current := range segments {
if i >= len(parts) {
return nil, false
}
if current.Wildcard {
params[current.Name] = "/" + strings.Join(parts[i:], "/")
return params, true
}
if current.Name != "" {
if parts[i] == "" {
return nil, false
}
params[current.Name] = parts[i]
} else if current.Literal != parts[i] {
return nil, false
}
}
return params, len(segments) == len(parts)
}

View File

@@ -1,71 +1,47 @@
// Package nethttp provides a small standard-library router for GoOmni.
// It is intentionally independent of Gin and serves as the reference for a
// future project-owned network layer.
// Package nethttp provides a standard-library router for GoOmni, independent
// of Gin. Register routes before serving requests.
package nethttp
import (
"net/http"
"sort"
"strings"
"sync"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/transport/internal/routing"
)
// Router implements httpx.Router with net/http only. Register routes before
// serving requests.
type Router struct {
mu sync.RWMutex
routes []route
}
type Router struct{ routes []route }
type route struct {
method string
pattern string
segments []segment
segments []routing.Segment
handler http.Handler
score int
}
type segment struct {
literal string
name string
wildcard bool
}
// New creates an empty standard-library router.
func New() *Router { return &Router{} }
// ServeHTTP dispatches a request and injects route params and the matched
// pattern into its context for the transport-neutral handler chain.
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
r.mu.RLock()
routes := append([]route(nil), r.routes...)
r.mu.RUnlock()
var selected *route
var selectedParams map[string]string
pathMatched := false
allow := make(map[string]struct{})
for index := range routes {
candidate := &routes[index]
params, matched := match(candidate.segments, request.URL.Path)
allow := make(map[string]bool)
for index := range r.routes {
candidate := &r.routes[index]
params, matched := routing.Match(candidate.segments, request.URL.Path)
if !matched {
continue
}
pathMatched = true
if candidate.method != "*" && candidate.method != request.Method {
allow[candidate.method] = struct{}{}
if candidate.method != request.Method {
allow[candidate.method] = true
continue
}
if selected == nil || candidate.score > selected.score {
selected = candidate
selectedParams = params
if selected == nil || moreSpecific(candidate.segments, selected.segments) {
selected, selectedParams = candidate, params
}
}
if selected == nil {
if pathMatched {
if len(allow) > 0 {
methods := make([]string, 0, len(allow))
for method := range allow {
methods = append(methods, method)
@@ -73,37 +49,34 @@ func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
sort.Strings(methods)
writer.Header().Set("Allow", strings.Join(methods, ", "))
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
} else {
http.NotFound(writer, request)
}
http.NotFound(writer, request)
return
}
requestContext := httpx.WithRouteParams(request.Context(), selectedParams)
requestContext = httpx.WithRoutePattern(requestContext, selected.pattern)
selected.handler.ServeHTTP(writer, request.WithContext(requestContext))
ctx := httpx.WithRouteParams(request.Context(), selectedParams)
ctx = httpx.WithRoutePattern(ctx, selected.pattern)
selected.handler.ServeHTTP(writer, request.WithContext(ctx))
}
func (r *Router) Handle(method, path string, handler http.Handler) {
if handler == nil {
panic("go-omni/nethttp: nil route handler")
segments := routing.Parse(method, path, handler)
for _, existing := range r.routes {
if existing.method == method {
routing.CheckConflict(existing.segments, segments)
}
}
segments := parsePattern(path)
r.mu.Lock()
r.routes = append(r.routes, route{
method: method,
pattern: cleanPattern(path),
segments: segments,
handler: handler,
score: routeScore(method, segments),
})
r.mu.Unlock()
r.routes = append(r.routes, route{method, path, segments, handler})
}
func (r *Router) Any(path string, handler http.Handler) { r.Handle("*", path, handler) }
func (r *Router) Any(path string, handler http.Handler) {
for _, method := range routing.Methods {
r.Handle(method, path, handler)
}
}
func (r *Router) Group(path string) httpx.RouterGroup {
return &group{router: r, prefix: cleanPattern(path)}
return &group{router: r, prefix: routing.Join("", path)}
}
type group struct {
@@ -112,107 +85,24 @@ type group struct {
}
func (g *group) Handle(method, path string, handler http.Handler) {
g.router.Handle(method, joinPattern(g.prefix, path), handler)
g.router.Handle(method, routing.Join(g.prefix, path), handler)
}
func (g *group) Any(path string, handler http.Handler) { g.Handle("*", path, handler) }
func (g *group) Any(path string, handler http.Handler) {
g.router.Any(routing.Join(g.prefix, path), handler)
}
func (g *group) Group(path string) httpx.RouterGroup {
return &group{router: g.router, prefix: joinPattern(g.prefix, path)}
return &group{router: g.router, prefix: routing.Join(g.prefix, path)}
}
func cleanPattern(pattern string) string {
if pattern == "" {
return "/"
// Compare from the left: an earlier static segment wins even if another
// matching route has more static segments later in its path.
func moreSpecific(a, b []routing.Segment) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if (a[i].Name == "") != (b[i].Name == "") {
return a[i].Name == ""
}
}
if !strings.HasPrefix(pattern, "/") {
pattern = "/" + pattern
}
pattern = strings.TrimRight(pattern, "/")
if pattern == "" {
return "/"
}
return pattern
}
func joinPattern(prefix, path string) string {
if prefix == "" || prefix == "/" {
return cleanPattern(path)
}
if path == "" || path == "/" {
return cleanPattern(prefix)
}
return cleanPattern(strings.TrimRight(prefix, "/") + "/" + strings.Trim(path, "/"))
}
func parsePattern(pattern string) []segment {
pattern = cleanPattern(pattern)
if pattern == "/" {
return nil
}
parts := strings.Split(strings.Trim(pattern, "/"), "/")
segments := make([]segment, 0, len(parts))
for _, part := range parts {
switch {
case strings.HasPrefix(part, ":") && len(part) > 1:
segments = append(segments, segment{name: part[1:]})
case strings.HasPrefix(part, "*") && len(part) > 1:
segments = append(segments, segment{name: part[1:], wildcard: true})
default:
segments = append(segments, segment{literal: part})
}
}
return segments
}
func match(segments []segment, path string) (map[string]string, bool) {
parts := splitPath(path)
params := make(map[string]string)
partIndex := 0
for segmentIndex, current := range segments {
if current.wildcard {
params[current.name] = strings.Join(parts[partIndex:], "/")
return params, true
}
if partIndex >= len(parts) {
return nil, false
}
if current.name != "" {
params[current.name] = parts[partIndex]
} else if current.literal != parts[partIndex] {
return nil, false
}
partIndex++
if segmentIndex == len(segments)-1 && partIndex != len(parts) {
return nil, false
}
}
if partIndex != len(parts) {
return nil, false
}
return params, true
}
func splitPath(path string) []string {
path = strings.Trim(path, "/")
if path == "" {
return nil
}
return strings.Split(path, "/")
}
func routeScore(method string, segments []segment) int {
score := len(segments)
if method != "*" {
score += 1000
}
for _, current := range segments {
if current.literal != "" {
score += 10
}
if current.wildcard {
score -= 5
}
}
return score
return false
}

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