Compare commits

..

1 Commits

Author SHA1 Message Date
5561795d80 feat: standardize transport contracts across Gin and nethttp 2026-09-21 00:10:44 +08:00
22 changed files with 1137 additions and 205 deletions

View File

@@ -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` 和数据库就绪探针;默认仍使用内存存储。

View File

@@ -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 类型。

View File

@@ -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。

View File

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

View File

@@ -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`,再下载依赖、

View File

@@ -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-]+)*)?$`)

View File

@@ -156,4 +156,6 @@ go vet ./...
go build ./...
```
`TestUserCRUD` 将同一份业务注册代码分别挂载到 Gin 与 nethttp,验证替换适配器后 Handler、Service、Repository 均无需修改。
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

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

View File

@@ -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")

View File

@@ -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")

View File

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

View File

@@ -1,8 +1,6 @@
# Transport adapters
GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。
使用标准库适配器:
业务 Handler 依赖 GoOmni 的 `httpx.Context`,Service 接收标准 `context.Context`。适配器只在启动入口组装:
```go
import (
@@ -11,12 +9,69 @@ import (
)
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)
}
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 "/"
}
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})
// 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 == ""
}
}
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())
})
}
}