Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61c37e0dba | |||
| 5561795d80 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## v0.3.1
|
||||
|
||||
- 新增独立业务模块验收 `scripts/verifyBusiness.py`:从远程 `v0.2.0` 升级到 `v0.3.1`,验证无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
||||
- 脚手架与各项远程验收脚本默认跟随 `v0.3.1`,新项目会固定到最新补丁版本。
|
||||
|
||||
## 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`。
|
||||
- 新增业务试用验收 `scripts/verifyBusiness.py`:独立目录内固定远程版本,执行 `v0.2.0` → `v0.3.0` 升级,验证生成业务服务的配置、日志、requestId、CRUD、参数校验、错误映射和优雅退出。
|
||||
|
||||
## v0.2.0
|
||||
|
||||
- 新增 `go-omni new --database mysql`,生成数据库配置、MySQL Repository、ServiceContext、`schema.sql` 和数据库就绪探针;默认仍使用内存存储。
|
||||
|
||||
16
README.md
16
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.1
|
||||
```
|
||||
|
||||
创建新业务项目:
|
||||
|
||||
```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.1
|
||||
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.1 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
|
||||
|
||||
`app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。
|
||||
|
||||
@@ -164,6 +166,14 @@ userService := service.NewUsers(services)
|
||||
|
||||
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
|
||||
|
||||
从远程版本生成并升级一个独立业务模块,可运行:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyBusiness.py --version v0.3.1
|
||||
```
|
||||
|
||||
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.3.1`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
||||
|
||||
在框架根目录运行:
|
||||
|
||||
```sh
|
||||
|
||||
16
ROADMAP.md
16
ROADMAP.md
@@ -18,15 +18,21 @@
|
||||
- 已验证初始化失败、缺少表结构、正常退出时关闭连接池,以及数据库停止后的 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. 实际业务试用(待完成)
|
||||
## 4. 实际业务试用(已完成,v0.3.1)
|
||||
|
||||
- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。
|
||||
- 根据真实接入问题收敛公共 API,再评估稳定版范围。
|
||||
- 在独立业务目录生成 billing service,先固定远程 `v0.2.0`,再升级到 `v0.3.1`,全程无本地 replace。
|
||||
- 验证环境配置、结构化访问日志、requestId 关联、参数校验、业务错误映射、CRUD、优雅退出和升级后的 race 测试。
|
||||
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
|
||||
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.3.1`。
|
||||
|
||||
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL 功能验收已完成,生产压测、自研网络内核与实际业务接入尚未完成。
|
||||
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,生产压测与自研网络内核尚未完成。后续进入稳定性与性能验证,再决定稳定版范围。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.1
|
||||
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.1 --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.1
|
||||
python3 scripts/verifyDatabase.py --version v0.3.1
|
||||
```
|
||||
|
||||
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
||||
|
||||
@@ -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.1"
|
||||
|
||||
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-]+)*)?$`)
|
||||
|
||||
|
||||
@@ -156,4 +156,6 @@ go vet ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
`TestUserCRUD` 将同一份业务注册代码分别挂载到 Gin 与 nethttp,验证替换适配器后 Handler、Service、Repository 均无需修改。
|
||||
|
||||
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
57
middleware/middleware_test.go
Normal file
57
middleware/middleware_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
137
scripts/verifyBusiness.py
Executable file
137
scripts/verifyBusiness.py
Executable file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a real generated business module across a released framework upgrade."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from verifyScaffold import MODULE, run
|
||||
|
||||
|
||||
def free_address():
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return f"127.0.0.1:{listener.getsockname()[1]}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--go", default="go")
|
||||
parser.add_argument("--version", default="v0.3.1", help="Target framework release")
|
||||
parser.add_argument("--from-version", default="v0.2.0", help="Version used before upgrade")
|
||||
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 business ") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
env["GOBIN"] = str(root / "bin")
|
||||
print(f"Installing released CLI {args.version}", flush=True)
|
||||
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], root, env)
|
||||
cli = root / "bin" / "go-omni"
|
||||
|
||||
project = root / "billing service"
|
||||
run([
|
||||
str(cli), "new", "--database", "memory", "--framework-version",
|
||||
args.from_version, "--module", "example.com/billing", str(project),
|
||||
], root, env)
|
||||
run([args.go, "mod", "tidy"], project, env)
|
||||
manifest = (project / "go.mod").read_text()
|
||||
if f"{MODULE} {args.from_version}" not in manifest or "replace " in manifest:
|
||||
raise AssertionError("initial business project was not pinned to the expected remote version")
|
||||
run([args.go, "test", "-race", "./..."], project, env)
|
||||
run([args.go, "get", f"{MODULE}@{args.version}"], project, env)
|
||||
run([args.go, "mod", "tidy"], project, env)
|
||||
upgraded = (project / "go.mod").read_text()
|
||||
if f"{MODULE} {args.version}" not in upgraded or "replace " in upgraded:
|
||||
raise AssertionError("business project upgrade did not produce a clean remote pin")
|
||||
run([args.go, "test", "-race", "./..."], project, env)
|
||||
binary = project / "billing-server"
|
||||
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
|
||||
|
||||
address = free_address()
|
||||
log_file = project / "business.log"
|
||||
server_env = dict(env)
|
||||
server_env.update(
|
||||
GO_OMNI_HTTP_ADDR=address,
|
||||
GO_OMNI_MODE="release",
|
||||
GO_OMNI_ENABLE_LOGGER="true",
|
||||
GO_OMNI_LOG_FILE=str(log_file),
|
||||
GO_OMNI_LOG_FORMAT="json",
|
||||
GO_OMNI_SHUTDOWN_TIMEOUT="2s",
|
||||
)
|
||||
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
def request(method, path, body=None, request_id="business-request"):
|
||||
payload = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://{address}{path}", data=payload, method=method,
|
||||
headers={"Content-Type": "application/json", "X-Request-ID": request_id},
|
||||
)
|
||||
try:
|
||||
with client.open(req, timeout=3) as response:
|
||||
return response.status, response.headers, json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.headers, json.loads(error.read())
|
||||
|
||||
process = subprocess.Popen(
|
||||
[str(binary)], cwd=project, env=server_env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
output = process.stdout.read() if process.stdout else ""
|
||||
raise RuntimeError(f"business service exited during startup: {output}")
|
||||
try:
|
||||
status, _, _ = request("GET", "/health/live")
|
||||
if status == 200:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("business service did not start")
|
||||
time.sleep(0.05)
|
||||
|
||||
status, headers, body = request("GET", "/health/ready")
|
||||
if status != 200 or body.get("code") != "READY":
|
||||
raise AssertionError(f"readiness failed: {status} {body}")
|
||||
status, headers, body = request("POST", "/users", {"name": "broken"}, "invalid-request")
|
||||
if status != 400 or body.get("code") != "INVALID_BODY" or headers.get("X-Request-ID") != "invalid-request":
|
||||
raise AssertionError(f"validation contract failed: {status} {body}")
|
||||
status, _, body = request("POST", "/users", {"name": "Alice", "email": "alice@example.com"}, "create-request")
|
||||
if status != 201 or body.get("data", {}).get("email") != "alice@example.com":
|
||||
raise AssertionError(f"business create failed: {status} {body}")
|
||||
status, _, body = request("POST", "/users", {"name": "Duplicate", "email": "ALICE@example.com"}, "duplicate-request")
|
||||
if status != 409 or body.get("code") != "EMAIL_TAKEN":
|
||||
raise AssertionError(f"business error mapping failed: {status} {body}")
|
||||
process.send_signal(signal.SIGTERM)
|
||||
if process.wait(timeout=8) != 0:
|
||||
raise AssertionError("business service did not exit cleanly")
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
content = log_file.read_text()
|
||||
if '"http.request"' not in content or '"requestId":"create-request"' not in content:
|
||||
raise AssertionError(f"access log did not contain business request correlation: {content}")
|
||||
if '"service":"example.com/billing"' not in content:
|
||||
raise AssertionError(f"business service name was not attached to logs: {content}")
|
||||
if "private" in content.lower() and "password" in content.lower():
|
||||
raise AssertionError("sensitive text leaked into business logs")
|
||||
|
||||
print(f"PASS business module: {args.from_version} -> {args.version}, clean remote pin, CRUD, validation, errors, requestId and logs", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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.1")
|
||||
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")
|
||||
|
||||
@@ -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.1", 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")
|
||||
|
||||
66
scripts/verifyTransports.py
Normal file
66
scripts/verifyTransports.py
Normal 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.1")
|
||||
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()
|
||||
@@ -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`;本阶段没有实现新的网络内核。
|
||||
|
||||
22
transport/contract_test.go
Normal file
22
transport/contract_test.go
Normal 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) })
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
119
transport/internal/routing/pattern.go
Normal file
119
transport/internal/routing/pattern.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
406
transport/transporttest/application.go
Normal file
406
transport/transporttest/application.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package transporttest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/app"
|
||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func application(t *testing.T, newRouter func() httpx.Router) {
|
||||
t.Run("explicit application methods", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
mark := func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
c.SetResponseHeader("X-Middleware", "present")
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
a.Handle("HEAD", "/users", func(c *httpx.Context) error { return c.NoContent() }, mark)
|
||||
group := a.Group("/api", mark)
|
||||
group.Handle("OPTIONS", "/users", func(c *httpx.Context) error { return c.NoContent() })
|
||||
group.Handle("REPORT", "/users", func(c *httpx.Context) error { return c.OK("report") })
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
status int
|
||||
}{
|
||||
{"HEAD", "/users", 204}, {"OPTIONS", "/api/users", 204}, {"REPORT", "/api/users", 200},
|
||||
} {
|
||||
w := request(a, tc.method, tc.path, "")
|
||||
if w.Code != tc.status || w.Header().Get("X-Middleware") != "present" || w.Header().Get("X-Request-ID") == "" {
|
||||
t.Fatalf("%s bypassed the application chain: %d %v", tc.method, w.Code, w.Header())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("middleware order and registration snapshot", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
var calls []string
|
||||
mark := func(name string) httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
calls = append(calls, name+" in")
|
||||
err := next(c)
|
||||
calls = append(calls, name+" out")
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
a.Use(mark("app"))
|
||||
group := a.Group("/api", mark("group"))
|
||||
nested := group.Group("/v1", mark("nested"))
|
||||
nested.GET("/users/:id", func(c *httpx.Context) error {
|
||||
calls = append(calls, "handler")
|
||||
return c.OK(c.Param("id"))
|
||||
}, mark("route"))
|
||||
a.Use(mark("lateApp"))
|
||||
group.Use(mark("lateGroup"))
|
||||
request(a, "GET", "/api/v1/users/42", "")
|
||||
want := "app in,group in,nested in,route in,handler,route out,nested out,group out,app out"
|
||||
if got := strings.Join(calls, ","); got != want {
|
||||
t.Fatalf("middleware order=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("binding and business context", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
var logs bytes.Buffer
|
||||
logger, err := logx.New(logx.Config{Format: "json", Writer: &logs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deadline := time.Now().Add(time.Minute)
|
||||
a.POST("/users/:id", func(c *httpx.Context) error {
|
||||
var uri struct {
|
||||
ID int `uri:"id" binding:"min=1"`
|
||||
}
|
||||
var query struct {
|
||||
PageSize int `form:"pageSize" binding:"min=1"`
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
if err := c.BindURI(&uri); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.BindQuery(&query); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.BindJSON(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
// Business services consume standard context only.
|
||||
result, err := businessResult(c.Context(), logger, deadline, uri.ID, query.PageSize, body.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Created(result)
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/users/42?pageSize=20", strings.NewReader(`{"name":"Ada"}`))
|
||||
ctx, cancel := context.WithDeadline(context.WithValue(req.Context(), contextKey{}, "original"), deadline)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("X-Request-ID", "businessRequest")
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, req)
|
||||
if w.Code != 201 || !strings.Contains(w.Body.String(), `"pageSize":20`) || !strings.Contains(w.Body.String(), `"requestId":"businessRequest"`) {
|
||||
t.Fatalf("unexpected business response: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if !strings.Contains(logs.String(), `"requestId":"businessRequest"`) {
|
||||
t.Fatalf("business log lost request context: %s", &logs)
|
||||
}
|
||||
for _, tc := range []struct{ path, body, code string }{
|
||||
{"/users/bad?pageSize=20", `{"name":"Ada"}`, "INVALID_PATH"},
|
||||
{"/users/42?pageSize=bad", `{"name":"Ada"}`, "INVALID_QUERY"},
|
||||
{"/users/42?pageSize=20", "{", "INVALID_BODY"},
|
||||
{"/users/42?pageSize=20", "{}", "INVALID_BODY"},
|
||||
} {
|
||||
w := request(a, "POST", tc.path, tc.body)
|
||||
assertEnvelope(t, w, 400, tc.code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("authentication short circuit", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
calls := 0
|
||||
provider := auth.StaticAuthenticator{
|
||||
"admin": {UserID: "adminUser", Roles: []string{"admin"}},
|
||||
"reader": {UserID: "readerUser"},
|
||||
}
|
||||
a.GET("/private", func(c *httpx.Context) error {
|
||||
calls++
|
||||
p, ok := auth.FromContext(c)
|
||||
if !ok {
|
||||
return errors.New("missing principal")
|
||||
}
|
||||
return c.OK(p.UserID)
|
||||
}, auth.RequireAuth(provider), auth.RequireRole("admin"))
|
||||
for _, tc := range []struct {
|
||||
token string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"", 401, "UNAUTHENTICATED"}, {"invalid", 401, "UNAUTHENTICATED"},
|
||||
{"reader", 403, "FORBIDDEN"}, {"admin", 200, "OK"},
|
||||
} {
|
||||
req := httptest.NewRequest("GET", "/private", nil)
|
||||
if tc.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tc.token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, req)
|
||||
assertEnvelope(t, w, tc.status, tc.code)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("unauthorized requests reached business handler: %d calls", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error recovery and committed response", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
handler httpx.Handler
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"/notFound", func(c *httpx.Context) error {
|
||||
return fmt.Errorf("wrapped: %w", apierrors.NotFound("USER_MISSING", "user missing"))
|
||||
}, 404, "USER_MISSING"},
|
||||
{"/internal", func(c *httpx.Context) error { return errors.New("private database password") }, 500, "INTERNAL_ERROR"},
|
||||
{"/panic", func(c *httpx.Context) error { panic("private database password") }, 500, "INTERNAL_ERROR"},
|
||||
{"/encode", func(c *httpx.Context) error { return c.OK(make(chan int)) }, 500, "INTERNAL_ERROR"},
|
||||
{"/written", func(c *httpx.Context) error {
|
||||
if err := c.Created("saved"); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("private database password")
|
||||
}, 201, "CREATED"},
|
||||
{"/panicAfterWrite", func(c *httpx.Context) error { _ = c.Accepted("saved"); panic("private database password") }, 202, "ACCEPTED"},
|
||||
{"/doubleWrite", func(c *httpx.Context) error { _ = c.OK("first"); return c.Created("second") }, 200, "OK"},
|
||||
} {
|
||||
a.GET(tc.path, tc.handler)
|
||||
w := request(a, "GET", tc.path, "")
|
||||
assertEnvelope(t, w, tc.status, tc.code)
|
||||
if strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatalf("internal cause leaked: %s", w.Body)
|
||||
}
|
||||
}
|
||||
a.DELETE("/deleted", func(c *httpx.Context) error {
|
||||
_ = c.NoContent()
|
||||
return errors.New("after response")
|
||||
})
|
||||
w := request(a, "DELETE", "/deleted", "")
|
||||
if w.Code != 204 || w.Body.Len() != 0 {
|
||||
t.Fatalf("204 response=%d %s", w.Code, w.Body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent request isolation", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
a.GET("/users/:id", func(c *httpx.Context) error {
|
||||
c.Set("local", c.Param("id"))
|
||||
value, _ := c.Get("local")
|
||||
return c.OK(value)
|
||||
})
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
workers.Add(1)
|
||||
go func(i int) {
|
||||
defer workers.Done()
|
||||
id := fmt.Sprint(i)
|
||||
req := httptest.NewRequest("GET", "/users/"+id, nil)
|
||||
req.Header.Set("X-Request-ID", "request"+id)
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, req)
|
||||
var body struct {
|
||||
Data string
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if err != nil || body.Data != id || body.RequestID != "request"+id || w.Header().Get("X-Request-ID") != "request"+id {
|
||||
t.Errorf("request %s leaked state: %s, error=%v", id, w.Body, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
workers.Wait()
|
||||
})
|
||||
|
||||
t.Run("middleware panic", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
a.GET("/panic", func(c *httpx.Context) error {
|
||||
t.Error("handler reached after middleware panic")
|
||||
return nil
|
||||
}, func(httpx.Handler) httpx.Handler {
|
||||
return func(*httpx.Context) error { panic("private middleware state") }
|
||||
})
|
||||
assertEnvelope(t, request(a, "GET", "/panic", ""), 500, "INTERNAL_ERROR")
|
||||
})
|
||||
|
||||
t.Run("write failure preserves committed response", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
a.GET("/write", func(c *httpx.Context) error { return c.Created("value") })
|
||||
w := &failedWriter{ResponseRecorder: httptest.NewRecorder()}
|
||||
a.ServeHTTP(w, httptest.NewRequest("GET", "/write", nil))
|
||||
if w.Code != 201 || w.headers != 1 || w.writes != 1 {
|
||||
t.Fatalf("error response appended after failed write: status=%d headers=%d writes=%d", w.Code, w.headers, w.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired deadline reaches service", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
a.GET("/deadline", func(c *httpx.Context) error {
|
||||
if !errors.Is(c.Context().Err(), context.DeadlineExceeded) {
|
||||
t.Errorf("deadline lost: %v", c.Context().Err())
|
||||
}
|
||||
select {
|
||||
case <-c.Context().Done():
|
||||
default:
|
||||
t.Error("expired request context was replaced")
|
||||
}
|
||||
return c.Context().Err()
|
||||
})
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
req := httptest.NewRequest("GET", "/deadline", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, req)
|
||||
assertEnvelope(t, w, 500, "INTERNAL_ERROR")
|
||||
})
|
||||
|
||||
t.Run("client cancellation reaches service", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
started := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
stop := make(chan struct{})
|
||||
a.GET("/wait/:id", func(c *httpx.Context) error {
|
||||
close(started)
|
||||
err := waitService(c.Context(), stop)
|
||||
done <- err
|
||||
return err
|
||||
})
|
||||
server := httptest.NewServer(a)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() { cancel(); close(stop); server.Close() }()
|
||||
clientDone := make(chan error, 1)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", server.URL+"/wait/42", nil)
|
||||
go func() {
|
||||
resp, err := server.Client().Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
clientDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("handler did not start")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("service cancellation=%v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("client cancellation did not reach service")
|
||||
}
|
||||
select {
|
||||
case err := <-clientDone:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("client cancellation=%v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("client did not return")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HEAD over a real connection", func(t *testing.T) {
|
||||
a := app.NewWithRouter(newRouter())
|
||||
a.Any("/head", func(c *httpx.Context) error { return c.OK("body") })
|
||||
server := httptest.NewServer(a)
|
||||
defer server.Close()
|
||||
req, _ := http.NewRequest("HEAD", server.URL+"/head", nil)
|
||||
client := server.Client()
|
||||
client.Timeout = 5 * time.Second
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode != 200 || len(body) != 0 || resp.Header.Get("X-Request-ID") == "" {
|
||||
t.Fatalf("HEAD status=%d body=%q error=%v", resp.StatusCode, body, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func businessResult(ctx context.Context, logger *logx.Logger, deadline time.Time, id, size int, name string) (any, error) {
|
||||
got, ok := ctx.Deadline()
|
||||
if !ok || !got.Equal(deadline) || ctx.Value(contextKey{}) != "original" || logx.RequestID(ctx) != "businessRequest" {
|
||||
return nil, errors.New("service lost request context")
|
||||
}
|
||||
logger.InfoContext(ctx, "business.read", "userId", id)
|
||||
return map[string]any{"userId": id, "pageSize": size, "name": name}, nil
|
||||
}
|
||||
|
||||
func waitService(ctx context.Context, stop <-chan struct{}) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-stop:
|
||||
return errors.New("test cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func request(handler http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func assertEnvelope(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Code string
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid response JSON: %s: %v", w.Body, err)
|
||||
}
|
||||
if w.Code != status || body.Code != code || body.RequestID == "" || body.RequestID != w.Header().Get("X-Request-ID") {
|
||||
t.Fatalf("response=%d %s; want %d %s with requestId", w.Code, w.Body, status, code)
|
||||
}
|
||||
}
|
||||
|
||||
type failedWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
headers, writes int
|
||||
}
|
||||
|
||||
func (w *failedWriter) WriteHeader(status int) {
|
||||
w.headers++
|
||||
w.ResponseRecorder.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *failedWriter) Write([]byte) (int, error) {
|
||||
w.writes++
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
198
transport/transporttest/router.go
Normal file
198
transport/transporttest/router.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Package transporttest provides the reusable acceptance suite for GoOmni
|
||||
// router adapters. Import it only from tests and pass a fresh router factory.
|
||||
// Register routes before serving; the suite exercises concurrent requests.
|
||||
package transporttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
// Run verifies routing and the same business handlers on a supplied adapter.
|
||||
// Factories are called serially and must return a fresh, empty router.
|
||||
func Run(t *testing.T, newRouter func() httpx.Router) {
|
||||
t.Helper()
|
||||
t.Run("routing", func(t *testing.T) { routing(t, newRouter) })
|
||||
t.Run("registration", func(t *testing.T) { registration(t, newRouter) })
|
||||
t.Run("application", func(t *testing.T) { application(t, newRouter) })
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
type routeResult struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Params map[string]string `json:"params"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func routing(t *testing.T, newRouter func() httpx.Router) {
|
||||
r := newRouter()
|
||||
echo := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
value, _ := req.Context().Value(contextKey{}).(string)
|
||||
_ = json.NewEncoder(w).Encode(routeResult{
|
||||
Pattern: httpx.RoutePattern(req.Context()), Params: httpx.RouteParams(req.Context()), Value: value,
|
||||
})
|
||||
})
|
||||
for _, path := range []string{
|
||||
"/", "/users/:id", "/users/me", "/files/*path", "/slash/",
|
||||
"/choice/:first/fixed/final", "/choice/static/:second/:third",
|
||||
"/back/static", "/back/:id/details",
|
||||
} {
|
||||
r.Handle(http.MethodGet, path, echo)
|
||||
}
|
||||
// Registration order must not determine static-vs-parameter precedence.
|
||||
r.Handle(http.MethodPost, "/users/me", echo)
|
||||
r.Handle(http.MethodPost, "/users/:id", echo)
|
||||
r.Handle(http.MethodHead, "/head", echo)
|
||||
r.Handle(http.MethodOptions, "/head", echo)
|
||||
r.Handle("REPORT", "/report", echo)
|
||||
group := r.Group("/api/").Group("v1")
|
||||
group.Handle(http.MethodGet, "/users/:id", echo)
|
||||
group.Handle(http.MethodGet, "", echo)
|
||||
group.Handle(http.MethodGet, "/", echo)
|
||||
group.Any("/any", echo)
|
||||
r.Any("/any", echo)
|
||||
|
||||
cases := []struct {
|
||||
method, path, pattern, param, value string
|
||||
status int
|
||||
allow string
|
||||
}{
|
||||
{"GET", "/", "/", "", "", 200, ""},
|
||||
{"GET", "/users/42?x=y", "/users/:id", "id", "42", 200, ""},
|
||||
{"GET", "/users/me", "/users/me", "", "", 200, ""},
|
||||
{"POST", "/users/me", "/users/me", "", "", 200, ""},
|
||||
{"GET", "/users/%E4%B8%AD%E6%96%87", "/users/:id", "id", "中文", 200, ""},
|
||||
{"GET", "/users/a%2Fb", "", "", "", 404, ""},
|
||||
{"GET", "/users/a+b", "/users/:id", "id", "a+b", 200, ""},
|
||||
{"GET", "/users/", "", "", "", 404, ""},
|
||||
{"GET", "/users/42/", "", "", "", 404, ""},
|
||||
{"GET", "/users//42", "", "", "", 404, ""},
|
||||
{"GET", "/Users/42", "", "", "", 404, ""},
|
||||
{"GET", "/slash", "", "", "", 404, ""},
|
||||
{"GET", "/slash/", "/slash/", "", "", 200, ""},
|
||||
{"GET", "/files", "", "", "", 404, ""},
|
||||
{"GET", "/files/", "/files/*path", "path", "/", 200, ""},
|
||||
{"GET", "/files/a%2Fb/c", "/files/*path", "path", "/a/b/c", 200, ""},
|
||||
{"GET", "/files//a", "/files/*path", "path", "//a", 200, ""},
|
||||
{"GET", "/choice/static/fixed/final", "/choice/static/:second/:third", "second", "fixed", 200, ""},
|
||||
{"GET", "/back/static/details", "/back/:id/details", "id", "static", 200, ""},
|
||||
{"GET", "/api/v1/users/42", "/api/v1/users/:id", "id", "42", 200, ""},
|
||||
{"GET", "/api/v1", "/api/v1", "", "", 200, ""},
|
||||
{"GET", "/api/v1/", "/api/v1/", "", "", 200, ""},
|
||||
{"REPORT", "/report", "/report", "", "", 200, ""},
|
||||
{"HEAD", "/head", "/head", "", "", 200, ""},
|
||||
{"OPTIONS", "/head", "/head", "", "", 200, ""},
|
||||
{"HEAD", "/users/42", "", "", "", 405, "GET, POST"},
|
||||
{"OPTIONS", "/users/42", "", "", "", 405, "GET, POST"},
|
||||
{"DELETE", "/users/me", "", "", "", 405, "GET, POST"},
|
||||
{"GET", "/head", "", "", "", 405, "HEAD, OPTIONS"},
|
||||
{"GET", "/missing", "", "", "", 404, ""},
|
||||
{"REPORT", "/any", "", "", "", 405, "CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE"},
|
||||
}
|
||||
for _, method := range []string{"GET", "POST", "PUT", "PATCH", "HEAD", "OPTIONS", "DELETE", "CONNECT", "TRACE"} {
|
||||
for _, path := range []string{"/any", "/api/v1/any"} {
|
||||
cases = append(cases, struct {
|
||||
method, path, pattern, param, value string
|
||||
status int
|
||||
allow string
|
||||
}{method, path, path, "", "", 200, ""})
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
// CONNECT has authority-form handling in net/http's parser; the
|
||||
// adapter contract consumes an already parsed URL.Path.
|
||||
if tc.method == "CONNECT" {
|
||||
req.URL.Path = tc.path
|
||||
}
|
||||
req = req.WithContext(context.WithValue(req.Context(), contextKey{}, "preserved"))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.status || w.Header().Get("Allow") != tc.allow || w.Header().Get("Location") != "" {
|
||||
t.Fatalf("status=%d allow=%q location=%q body=%s", w.Code, w.Header().Get("Allow"), w.Header().Get("Location"), w.Body)
|
||||
}
|
||||
if tc.status != 200 {
|
||||
want := "404 page not found\n"
|
||||
if tc.status == 405 {
|
||||
want = "Method Not Allowed\n"
|
||||
}
|
||||
if w.Body.String() != want || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
|
||||
t.Fatalf("unexpected fallback response: %s", w.Body)
|
||||
}
|
||||
return
|
||||
}
|
||||
var got routeResult
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantParams := map[string]string{}
|
||||
if tc.param != "" {
|
||||
wantParams[tc.param] = tc.value
|
||||
}
|
||||
// Routes with several parameters are checked in full.
|
||||
if tc.pattern == "/choice/static/:second/:third" {
|
||||
wantParams["third"] = "final"
|
||||
}
|
||||
if got.Pattern != tc.pattern || !reflect.DeepEqual(got.Params, wantParams) || got.Value != "preserved" {
|
||||
t.Fatalf("route metadata=%+v, want %s %v with original context", got, tc.pattern, wantParams)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func registration(t *testing.T, newRouter func() httpx.Router) {
|
||||
handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
cases := []struct {
|
||||
name string
|
||||
register func(httpx.Router)
|
||||
}{
|
||||
{"nil handler", func(r httpx.Router) { r.Handle("GET", "/", nil) }},
|
||||
{"empty method", func(r httpx.Router) { r.Handle("", "/", handler) }},
|
||||
{"lowercase method", func(r httpx.Router) { r.Handle("get", "/", handler) }},
|
||||
}
|
||||
for _, path := range []string{"", "relative", "/:", "/*", "/a/*rest/b", "/a/:id/:id", "/a//b", "/a/../b", "/a?b", "/prefix:id", "/:a:b"} {
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
register func(httpx.Router)
|
||||
}{"invalid " + path, func(r httpx.Router) { r.Handle("GET", path, handler) }})
|
||||
}
|
||||
for _, pair := range [][2]string{
|
||||
{"/users", "/users"}, {"/users/:id", "/users/:name/more"},
|
||||
{"/files/*path", "/files/static"}, {"/files/static", "/files/*path"},
|
||||
{"/", "/*path"}, {"/*path", "/"},
|
||||
} {
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
register func(httpx.Router)
|
||||
}{"conflict " + pair[0] + " " + pair[1], func(r httpx.Router) {
|
||||
r.Handle("GET", pair[0], handler)
|
||||
r.Handle("GET", pair[1], handler)
|
||||
}})
|
||||
}
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
register func(httpx.Router)
|
||||
}{"Any conflicts with explicit method", func(r httpx.Router) {
|
||||
r.Any("/any", handler)
|
||||
r.Handle("GET", "/any", handler)
|
||||
}})
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("registration must panic")
|
||||
}
|
||||
}()
|
||||
tc.register(newRouter())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user