diff --git a/CHANGELOG.md b/CHANGELOG.md index 8046069..c14daa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.3.0 + +- 新增公共适配器验收套件 `transport/transporttest.Run` 和远程消费验收脚本 `scripts/verifyTransports.py`。Gin、nethttp 使用同一套路由与业务处理链测试,并通过完整用户 CRUD 与 race 验证。 +- 统一参数、分组、方法、URL 解码、路由优先级及 404/405 规则;Gin 405 的 Allow 由适配层计算,避免方法树回退状态带入无关方法。 +- 统一路由注册校验;非法路径、重复路由、参数名冲突和 catch-all 冲突在注册时 panic。 +- `App.Handle` / `RouterGroup.Handle` 支持在完整业务中间件链内显式注册 HEAD、OPTIONS 与扩展方法。 +- JSON 在提交响应前编码,编码失败可正确返回统一 500;通过 Context 已提交的响应不会因后续错误、panic 或重复响应追加内容,访问日志保留实际提交的状态。 +- 验证 requestId 与业务日志关联、认证授权短路、请求并发隔离、context 值与 deadline 保留,以及真实客户端取消传入 Service。 +- **升级行为调整**:Gin 不再自动重定向尾部斜杠;nethttp 不再忽略尾部斜杠,通配参数包含开头的 `/`,Any 收敛为九种标准方法。HEAD 不自动回退 GET,其他方法需显式注册。404/405 为统一纯文本响应,405 附排序后的 Allow。 +- `gin.NewWithEngine` 应用 GoOmni 路由策略;原生 Gin 扩展与 Raw 流式响应不属于可移植契约。详见 `transport/README.md`。 + ## v0.2.0 - 新增 `go-omni new --database mysql`,生成数据库配置、MySQL Repository、ServiceContext、`schema.sql` 和数据库就绪探针;默认仍使用内存存储。 diff --git a/README.md b/README.md index b277bee..9975923 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gi 远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.2.0 +go get git.yuntuo.hk/k2safe/go-omni@v0.3.0 ``` 创建新业务项目: ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.2.0 +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.0 go-omni new --module example.com/billing ./billing cd billing go mod tidy @@ -86,7 +86,9 @@ return omni.NotFound("USER_NOT_FOUND", "user was not found") application := omni.NewWithRouter(myRouter) ``` -仓库还提供了只依赖标准库的 `transport/nethttp` 参考实现,可以直接验证这条替换路径。 +仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。 + +自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.3.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。 `app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。 diff --git a/ROADMAP.md b/ROADMAP.md index 0ae058c..6ed32f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,15 +18,19 @@ - 已验证初始化失败、缺少表结构、正常退出时关闭连接池,以及数据库停止后的 readiness 503、恢复后的 200。 - 自动验收入口:`python3 scripts/verifyDatabase.py --version v0.2.0`;结束后清理本次测试容器、匿名卷和临时项目。 -## 3. 网络适配一致性(待完成) +## 3. 网络适配一致性(已完成,v0.3.0) - Gin 与标准库适配器运行同一套契约测试。 - 覆盖路由参数、分组、中间件、错误响应和取消传播。 - 验收:切换适配器后,业务 Handler 和 Service 无需修改,约定的 HTTP 行为一致。 +- 新增公共 `transport/transporttest.Run`,自研适配器可直接复用;覆盖静态 / 参数 / 通配路由、分组、405 Allow、鉴权短路、错误与 panic、响应提交、requestId、并发隔离、截止时间及真实客户端取消。 +- 两种适配器共享明确的路径与方法约定,修复尾部斜杠、通配参数、Any 和 Gin 405 Allow 差异。 +- 完整用户 CRUD 已用相同业务注册代码在两种适配器上验证;全量 race 测试通过。 +- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.3.0`,从源码目录外消费远程版本与公共契约测试包。 ## 4. 实际业务试用(待完成) - 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。 - 根据真实接入问题收敛公共 API,再评估稳定版范围。 -以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL 功能验收已完成,生产压测、自研网络内核与实际业务接入尚未完成。 +以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL 与适配层契约验收已完成,生产压测、自研网络内核与实际业务接入尚未完成。下一阶段先在真实业务模块中使用固定远程版本,再根据接入反馈收敛 API。 diff --git a/app/router.go b/app/router.go index f5e2f66..192ce37 100644 --- a/app/router.go +++ b/app/router.go @@ -28,6 +28,12 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http. }) } +// Handle registers an explicit method, including HEAD, OPTIONS and extension +// methods, through the same business middleware and error handling as GET. +func (a *App) Handle(method, path string, handler httpx.Handler, middleware ...httpx.Middleware) { + a.handle(a.router, method, path, handler, middleware) +} + func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) { a.handle(a.router, http.MethodGet, path, handler, middleware) } @@ -80,6 +86,11 @@ func (g *RouterGroup) route(method, path string, handler httpx.Handler, middlewa g.app.handle(g.group, method, path, handler, combined) } +// Handle registers an explicit method within this group's middleware chain. +func (g *RouterGroup) Handle(method, path string, handler httpx.Handler, middleware ...httpx.Middleware) { + g.route(method, path, handler, middleware) +} + func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) { g.route(http.MethodGet, path, handler, middleware) } diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 2067563..4472c1c 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -3,7 +3,7 @@ 从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架: ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.2.0 +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.0 go-omni new --module example.com/billing ./billing cd billing go mod tidy @@ -16,7 +16,7 @@ go run ./cmd/server 需要选择其他已发布版本时,使用完整版本号: ```sh -go-omni new --framework-version v0.2.0 --module example.com/billing ./billing +go-omni new --framework-version v0.3.0 --module example.com/billing ./billing ``` `--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。 @@ -48,8 +48,8 @@ MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库 发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖: ```sh -python3 scripts/verifyScaffold.py --version v0.2.0 -python3 scripts/verifyDatabase.py --version v0.2.0 +python3 scripts/verifyScaffold.py --version v0.3.0 +python3 scripts/verifyDatabase.py --version v0.3.0 ``` 脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、 diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index e700bb8..7925965 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.2.0" +const defaultFrameworkVersion = "v0.3.0" var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) diff --git a/examples/reference/README.md b/examples/reference/README.md index 0a65656..6076c8f 100644 --- a/examples/reference/README.md +++ b/examples/reference/README.md @@ -156,4 +156,6 @@ go vet ./... go build ./... ``` +`TestUserCRUD` 将同一份业务注册代码分别挂载到 Gin 与 nethttp,验证替换适配器后 Handler、Service、Repository 均无需修改。 + 测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。 diff --git a/examples/reference/main.go b/examples/reference/main.go index bb5e2cd..aa54170 100644 --- a/examples/reference/main.go +++ b/examples/reference/main.go @@ -20,26 +20,24 @@ import ( func newApp(users service.UserRepository, logger *logx.Logger, options ...omni.Option) *omni.App { application := omni.New(options...) - application.GET("/health", func(ctx *omni.Context) error { - return ctx.OK(map[string]string{"status": "ok"}) - }) - - api := application.Group("/api/v1", handler.RequestLog(logger)) - handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, nil, nil) + registerRoutes(application, users, logger, nil, nil) return application } func newProtectedApp(users service.UserRepository, logger *logx.Logger, authenticator auth.Authenticator, options ...omni.Option) *omni.App { application := omni.New(options...) + registerRoutes(application, users, logger, auth.RequireAuth(authenticator), auth.RequireRole("admin")) + return application +} + +// Registration and business layers are shared by every network adapter. +func registerRoutes(application *omni.App, users service.UserRepository, logger *logx.Logger, write, delete omni.Middleware) { application.GET("/health", func(ctx *omni.Context) error { return ctx.OK(map[string]string{"status": "ok"}) }) api := application.Group("/api/v1", handler.RequestLog(logger)) - write := auth.RequireAuth(authenticator) - delete := auth.RequireRole("admin") handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, write, delete) - return application } func main() { diff --git a/examples/reference/main_test.go b/examples/reference/main_test.go index 2f4ab08..ede74fb 100644 --- a/examples/reference/main_test.go +++ b/examples/reference/main_test.go @@ -18,7 +18,10 @@ import ( "git.yuntuo.hk/k2safe/go-omni/auth" "git.yuntuo.hk/k2safe/go-omni/examples/reference/model" "git.yuntuo.hk/k2safe/go-omni/examples/reference/repository" + "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" + ginrouter "git.yuntuo.hk/k2safe/go-omni/transport/gin" + netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" ) type envelope struct { @@ -103,7 +106,23 @@ func dataAs[T any](t *testing.T, value envelope) T { } func TestUserCRUD(t *testing.T) { - app := testApp() + 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) { + application := omni.NewWithRouter(adapter.newRouter()) + registerRoutes(application, repository.NewMemoryUsers(), testLogger(io.Discard), nil, nil) + checkUserCRUD(t, application) + }) + } +} + +func checkUserCRUD(t *testing.T, app *omni.App) { + t.Helper() health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK")) if health["status"] != "ok" { t.Fatalf("unexpected health: %v", health) diff --git a/httpx/context.go b/httpx/context.go index b456e45..b148383 100644 --- a/httpx/context.go +++ b/httpx/context.go @@ -20,7 +20,9 @@ type Middleware func(Handler) Handler // Router is the small contract the application needs from a network layer. // The default implementation is backed by Gin, but another router or a // future GoOmni transport can implement this interface without changing -// business handlers. +// business handlers. Register routes before serving requests. Implementations +// should pass transport/transporttest.Run; see transport/README.md for the +// portable path, method, fallback and context-propagation rules. type Router interface { http.Handler Handle(method, path string, handler http.Handler) @@ -162,16 +164,25 @@ func (c *Context) BindURI(value any) error { return validate.URI(c.params, value // JSON is an escape hatch for responses that do not use the standard envelope. func (c *Context) JSON(status int, value any) error { + if c.responded { + return nil + } if c.writer == nil { return apierrors.Internal(nil) } - c.writer.Header().Set("Content-Type", "application/json; charset=utf-8") - c.writer.WriteHeader(status) - if err := json.NewEncoder(c.writer).Encode(value); err != nil { + // Encode before committing headers so an invalid value can still produce + // the standard 500 response on every adapter. + data, err := json.Marshal(value) + if err != nil { return apierrors.Internal(err) } c.responded = true c.status = status + c.writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.writer.WriteHeader(status) + if _, err := c.writer.Write(append(data, '\n')); err != nil { + return apierrors.Internal(err) + } return nil } @@ -186,9 +197,15 @@ func (c *Context) Accepted(data any) error { } func (c *Context) NoContent() error { - c.writer.WriteHeader(http.StatusNoContent) + if c.responded { + return nil + } + if c.writer == nil { + return apierrors.Internal(nil) + } c.responded = true c.status = http.StatusNoContent + c.writer.WriteHeader(http.StatusNoContent) return nil } @@ -213,7 +230,8 @@ func (c *Context) Status() int { func (c *Context) Responded() bool { return c.responded } // WriteError writes a stable public error response. Internal causes are not -// serialized and a partially written response is preserved. +// serialized. Responses committed through Context methods are preserved; +// direct Raw writes are outside this response-tracking contract. func (c *Context) WriteError(err error) { if c.responded { return diff --git a/middleware/middleware.go b/middleware/middleware.go index 173e973..65e0f0a 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -55,7 +55,7 @@ func AccessLog() httpx.Middleware { started := time.Now() err := next(ctx) status := ctx.Status() - if err != nil { + if err != nil && !ctx.Responded() { status = apierrors.From(err).Status } logx.InfoContext(ctx.Context(), "http.request", diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go new file mode 100644 index 0000000..5be9d77 --- /dev/null +++ b/middleware/middleware_test.go @@ -0,0 +1,57 @@ +package middleware_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http/httptest" + "testing" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/middleware" +) + +func TestAccessLogReportsCommittedStatus(t *testing.T) { + for _, tc := range []struct { + name string + handler httpx.Handler + status int + }{ + {"unwritten error", func(c *httpx.Context) error { return apierrors.NotFound("MISSING", "missing") }, 404}, + {"encoding error", func(c *httpx.Context) error { return c.OK(make(chan int)) }, 500}, + {"committed error", func(c *httpx.Context) error { _ = c.Created("saved"); return errors.New("later error") }, 201}, + {"committed panic", func(c *httpx.Context) error { _ = c.Accepted("saved"); panic("later panic") }, 202}, + } { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + logger, err := logx.New(logx.Config{Format: "json", Writer: &output}) + if err != nil { + t.Fatal(err) + } + logx.SetDefault(logger) + defer logx.SetDefault(nil) + req := httptest.NewRequest("GET", "/users/42", nil) + req.Header.Set("X-Request-ID", "logRequest") + req = req.WithContext(httpx.WithRoutePattern(req.Context(), "/users/:id")) + w := httptest.NewRecorder() + ctx := httpx.NewContext(req, w, nil) + chain := middleware.RequestID("X-Request-ID")(middleware.AccessLog()(middleware.Recovery()(tc.handler))) + if err := chain(ctx); err != nil { + ctx.WriteError(err) + } + var entry struct { + Status int + Route string + RequestID string `json:"requestId"` + } + if err := json.Unmarshal(output.Bytes(), &entry); err != nil { + t.Fatal(err) + } + if entry.Status != tc.status || w.Code != tc.status || entry.Route != "/users/:id" || entry.RequestID != "logRequest" { + t.Fatalf("log=%s response=%d; expected status %d and request metadata", &output, w.Code, tc.status) + } + }) + } +} diff --git a/scripts/verifyDatabase.py b/scripts/verifyDatabase.py index 4c0ef3f..5dd14de 100644 --- a/scripts/verifyDatabase.py +++ b/scripts/verifyDatabase.py @@ -145,7 +145,7 @@ class Server: def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--go", default="go") - parser.add_argument("--version", default="v0.2.0") + parser.add_argument("--version", default="v0.3.0") parser.add_argument("--cli", help="Use a locally built CLI") parser.add_argument("--framework-dir", help="Local framework source for pre-release checks") parser.add_argument("--image", default="mysql:8.4") diff --git a/scripts/verifyScaffold.py b/scripts/verifyScaffold.py index 12ab4a9..0c5faaa 100644 --- a/scripts/verifyScaffold.py +++ b/scripts/verifyScaffold.py @@ -91,7 +91,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--go", default="go", help="Go executable") - parser.add_argument("--version", default="v0.2.0", help="Released CLI version to install") + parser.add_argument("--version", default="v0.3.0", help="Released CLI version to install") parser.add_argument("--cli", help="Use a locally built CLI instead of installing") parser.add_argument("--framework-version", help="Explicit version passed to the CLI") parser.add_argument("--framework-dir", help="Local checkout for pre-release verification") diff --git a/scripts/verifyTransports.py b/scripts/verifyTransports.py new file mode 100644 index 0000000..5a60bd1 --- /dev/null +++ b/scripts/verifyTransports.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run the public adapter contract from an independent consumer module.""" + +import argparse +import json +import os +from pathlib import Path +import tempfile + +from verifyScaffold import MODULE, run + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--go", default="go") + parser.add_argument("--version", default="v0.3.0") + parser.add_argument("--framework-dir", help="Use a local checkout before release") + args = parser.parse_args() + env = dict(os.environ, GOWORK="off") + env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"])) + with tempfile.TemporaryDirectory(prefix="go omni transport ") as temporary: + directory = Path(temporary).resolve() + module = f"module example.com/adaptercheck\n\ngo 1.22\n\nrequire {MODULE} {args.version}\n" + if args.framework_dir: + module += f"\nreplace {MODULE} => {json.dumps(str(Path(args.framework_dir).resolve()), ensure_ascii=False)}\n" + (directory / "go.mod").write_text(module) + (directory / "adapter_test.go").write_text(f'''package adaptercheck_test + +import ( + "testing" + "{MODULE}/httpx" + ginrouter "{MODULE}/transport/gin" + netrouter "{MODULE}/transport/nethttp" + "{MODULE}/transport/transporttest" +) + +func TestContract(t *testing.T) {{ + t.Run("gin", func(t *testing.T) {{ + transporttest.Run(t, func() httpx.Router {{ return ginrouter.New("test") }}) + }}) + t.Run("nethttp", func(t *testing.T) {{ + transporttest.Run(t, func() httpx.Router {{ return netrouter.New() }}) + }}) +}} +''') + source = "local checkout" if args.framework_dir else f"remote {args.version}" + print(f"Testing {source} in an independent consumer module", flush=True) + run([args.go, "mod", "tidy"], directory, env) + if not args.framework_dir and "replace " in (directory / "go.mod").read_text(): + raise AssertionError("remote verification must not contain replace") + output = run([args.go, "test", "-race", "-count=1", "-json", "./..."], directory, env) + events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] + if any(event.get("Action") == "skip" for event in events): + raise AssertionError("contract suite unexpectedly skipped tests") + for adapter in ("gin", "nethttp"): + name = f"TestContract/{adapter}" + if not any(event.get("Action") == "pass" and event.get("Test") == name for event in events): + raise AssertionError(f"{adapter} contract did not pass") + count = sum(event.get("Action") == "pass" and event.get("Test", "").startswith(name + "/") + for event in events) + print(f"PASS {adapter}: {count} contract checks, including business handlers, cancellation and concurrent requests", flush=True) + print("PASS independent module: both adapters accepted by the same public contract suite", flush=True) + + +if __name__ == "__main__": + main() diff --git a/transport/README.md b/transport/README.md index d3bb1ef..c0deee1 100644 --- a/transport/README.md +++ b/transport/README.md @@ -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`;本阶段没有实现新的网络内核。 diff --git a/transport/contract_test.go b/transport/contract_test.go new file mode 100644 index 0000000..5ef0200 --- /dev/null +++ b/transport/contract_test.go @@ -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) }) + } +} diff --git a/transport/gin/router.go b/transport/gin/router.go index b4f7248..e3fc0a7 100644 --- a/transport/gin/router.go +++ b/transport/gin/router.go @@ -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)) diff --git a/transport/internal/routing/pattern.go b/transport/internal/routing/pattern.go new file mode 100644 index 0000000..2abed77 --- /dev/null +++ b/transport/internal/routing/pattern.go @@ -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) +} diff --git a/transport/nethttp/router.go b/transport/nethttp/router.go index 41eca49..8dc7c0e 100644 --- a/transport/nethttp/router.go +++ b/transport/nethttp/router.go @@ -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 } diff --git a/transport/transporttest/application.go b/transport/transporttest/application.go new file mode 100644 index 0000000..b9f5557 --- /dev/null +++ b/transport/transporttest/application.go @@ -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 +} diff --git a/transport/transporttest/router.go b/transport/transporttest/router.go new file mode 100644 index 0000000..e7bbeab --- /dev/null +++ b/transport/transporttest/router.go @@ -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()) + }) + } +}