From 8b19aa79eea0310e2cd2056a96a8d960bc52a020 Mon Sep 17 00:00:00 2001 From: k2safe Date: Sun, 20 Sep 2026 22:06:24 +0800 Subject: [PATCH] feat: bootstrap reusable GoOmni framework --- .gitignore | 7 + README.md | 157 +++++++++ app/app.go | 166 +++++++++ app/router.go | 107 ++++++ app_test.go | 132 +++++++ auth/README.md | 36 ++ auth/auth.go | 145 ++++++++ auth/auth_test.go | 122 +++++++ cmd/README.md | 3 + cmd/go-omni/README.md | 11 + cmd/go-omni/main.go | 85 +++++ cmd/go-omni/scaffold/scaffold.go | 184 ++++++++++ cmd/go-omni/scaffold/scaffold_test.go | 97 ++++++ cmd/go-omni/scaffold/templates/README.md.tmpl | 22 ++ .../templates/cmd-server-main.go.tmpl | 49 +++ cmd/go-omni/scaffold/templates/go.mod.tmpl | 8 + .../templates/internal-handler-user.go.tmpl | 31 ++ .../templates/internal-model-user.go.tmpl | 16 + .../internal-repository-memory.go.tmpl | 31 ++ .../templates/internal-service-user.go.tmpl | 21 ++ .../internal-servicecontext-context.go.tmpl | 17 + config/config.go | 93 +++++ config/config_test.go | 95 +++++ config/env.go | 67 ++++ database/README.md | 37 ++ database/database.go | 175 ++++++++++ database/database_test.go | 58 ++++ errors/errors.go | 74 ++++ examples/basic/main.go | 20 ++ examples/reference/README.md | 159 +++++++++ examples/reference/handler/middleware.go | 31 ++ examples/reference/handler/user.go | 121 +++++++ examples/reference/main.go | 100 ++++++ examples/reference/main_test.go | 277 +++++++++++++++ examples/reference/model/errors.go | 11 + examples/reference/model/user.go | 25 ++ examples/reference/repository/memory.go | 126 +++++++ examples/reference/repository/memory_test.go | 40 +++ examples/reference/repository/mysql.go | 122 +++++++ examples/reference/schema.sql | 9 + examples/reference/service/user.go | 71 ++++ go.mod | 38 ++ go.sum | 93 +++++ health/README.md | 17 + health/health.go | 153 ++++++++ health/health_test.go | 73 ++++ httpx/context.go | 227 ++++++++++++ logx/README.md | 31 ++ logx/logx.go | 327 ++++++++++++++++++ logx/logx_test.go | 37 ++ middleware/middleware.go | 82 +++++ middleware/request_id.go | 16 + omni.go | 67 ++++ response/response.go | 9 + router_test.go | 69 ++++ transport/README.md | 22 ++ transport/gin/router.go | 70 ++++ transport/nethttp/router.go | 218 ++++++++++++ transport/nethttp/router_test.go | 57 +++ validate/validate.go | 159 +++++++++ 60 files changed, 4923 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/app.go create mode 100644 app/router.go create mode 100644 app_test.go create mode 100644 auth/README.md create mode 100644 auth/auth.go create mode 100644 auth/auth_test.go create mode 100644 cmd/README.md create mode 100644 cmd/go-omni/README.md create mode 100644 cmd/go-omni/main.go create mode 100644 cmd/go-omni/scaffold/scaffold.go create mode 100644 cmd/go-omni/scaffold/scaffold_test.go create mode 100644 cmd/go-omni/scaffold/templates/README.md.tmpl create mode 100644 cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl create mode 100644 cmd/go-omni/scaffold/templates/go.mod.tmpl create mode 100644 cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl create mode 100644 cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl create mode 100644 cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl create mode 100644 cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl create mode 100644 cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 config/env.go create mode 100644 database/README.md create mode 100644 database/database.go create mode 100644 database/database_test.go create mode 100644 errors/errors.go create mode 100644 examples/basic/main.go create mode 100644 examples/reference/README.md create mode 100644 examples/reference/handler/middleware.go create mode 100644 examples/reference/handler/user.go create mode 100644 examples/reference/main.go create mode 100644 examples/reference/main_test.go create mode 100644 examples/reference/model/errors.go create mode 100644 examples/reference/model/user.go create mode 100644 examples/reference/repository/memory.go create mode 100644 examples/reference/repository/memory_test.go create mode 100644 examples/reference/repository/mysql.go create mode 100644 examples/reference/schema.sql create mode 100644 examples/reference/service/user.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 health/README.md create mode 100644 health/health.go create mode 100644 health/health_test.go create mode 100644 httpx/context.go create mode 100644 logx/README.md create mode 100644 logx/logx.go create mode 100644 logx/logx_test.go create mode 100644 middleware/middleware.go create mode 100644 middleware/request_id.go create mode 100644 omni.go create mode 100644 response/response.go create mode 100644 router_test.go create mode 100644 transport/README.md create mode 100644 transport/gin/router.go create mode 100644 transport/nethttp/router.go create mode 100644 transport/nethttp/router_test.go create mode 100644 validate/validate.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..547e197 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.idea/ +.vscode/ +bin/ +dist/ +*.log +coverage.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..e68825a --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# GoOmni + +GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gin 作为网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。 + +远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: + +```sh +go get git.yuntuo.hk/k2safe/go-omni@main +``` + +## 目录结构 + +```text +go-omni/ +├── app/ # 应用启动和生命周期 +├── httpx/ # 与网络框架无关的 HTTP 抽象 +├── response/ # 统一响应 +├── errors/ # 统一错误 +├── health/ # 存活和就绪检查 +├── middleware/ # 请求 ID 等基础中间件 +├── logx/ # 业务层结构化日志 +├── config/ # 配置管理 +├── validate/ # 参数校验 +├── auth/ # 认证和权限接口 +├── database/ # database/sql 生命周期和事务 +├── transport/gin/ # Gin 网络适配器,可替换 +├── examples/ # 示例项目 +└── cmd/ # 后续 CLI +``` + +## 快速开始 + +```go +package main + +import omni "git.yuntuo.hk/k2safe/go-omni" + +func main() { + app := omni.New() + + app.GET("/health", func(ctx *omni.Context) error { + return ctx.OK(map[string]string{"status": "ok"}) + }) + + app.Run(":8080") +} +``` + +成功响应默认使用统一结构: + +```json +{ + "code": "OK", + "message": "success", + "data": {"status": "ok"}, + "requestId": "..." +} +``` + +业务错误可以直接返回: + +```go +return omni.NotFound("USER_NOT_FOUND", "user was not found") +``` + +默认 Gin 适配器位于 `transport/gin`。业务层不需要导入 Gin;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用: + +```go +application := omni.NewWithRouter(myRouter) +``` + +仓库还提供了只依赖标准库的 `transport/nethttp` 参考实现,可以直接验证这条替换路径。 + +`app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。 + +业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖 Gin: + +```go +func (s *UserService) Create(ctx *httpx.Context) error { + logx.InfoContext(ctx.Context(), "user.create", "email", input.Email) + return nil +} +``` + +`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。 + +应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`: + +```go +settings, err := omni.LoadConfig("") +if err != nil { + log.Fatal(err) +} +logger, err := logx.New(settings.Log) +if err != nil { + log.Fatal(err) +} +application := omni.New(omni.WithConfig(settings)) +application.OnShutdown(logger.Close) +``` + +常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、 +`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、 +`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR` 和 `GO_OMNI_SHUTDOWN_TIMEOUT`。数据库连接仍由 +`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。 + +`App` 同时实现标准 `http.Handler`,并提供 `RunContext`:收到 `SIGINT` / `SIGTERM` 后可以先停止接收新请求,再在超时时间内等待活跃请求完成。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。 + +应用资源可以直接挂到 `omni.New` 返回的生命周期上: + +```go +application := omni.New() +application.OnShutdown(db.Close) +``` + +清理函数会按注册顺序的逆序执行,`application.Close()` 和 `RunContext` 都会触发,且重复调用安全。 + +服务可以把数据库、缓存等依赖注册为就绪探针: + +```go +checker, err := health.New(health.Probe{ + Name: "database", + Check: db.PingContext, +}) +if err != nil { + log.Fatal(err) +} +application.GET("/health/live", checker.LivenessHandler()) +application.GET("/health/ready", checker.ReadinessHandler()) +``` + +存活检查只确认进程在线;就绪检查会执行全部探针,任意依赖失败时返回 HTTP 503。 + +新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler: + +```go +services := servicecontext.New(logger, users) +userService := service.NewUsers(services) +``` + +## 完整业务参考项目 + +[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。 + +在框架根目录运行: + +```sh +go run ./examples/reference +``` + +默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。 + +框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入 Gin。 + +## 当前边界 + +缓存、消息队列、定时任务和 CLI 暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。 diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..8049f13 --- /dev/null +++ b/app/app.go @@ -0,0 +1,166 @@ +package app + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "git.yuntuo.hk/k2safe/go-omni/config" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/middleware" + ginrouter "git.yuntuo.hk/k2safe/go-omni/transport/gin" +) + +// App is the top-level GoOmni application. Its public behavior depends on +// httpx.Router, leaving the network implementation replaceable. +type App struct { + router httpx.Router + config config.Config + middlewares []httpx.Middleware + closers []func() error + closeOnce sync.Once + closeErr error +} + +// New creates an application using the default Gin adapter. +func New(options ...config.Option) *App { + settings := configure(options...) + return newApp(ginrouter.New(settings.Mode), settings) +} + +// NewWithRouter creates an application with a custom network layer. A custom +// router only needs to implement httpx.Router; handlers and business code stay +// unchanged when the transport changes. +func NewWithRouter(router httpx.Router, options ...config.Option) *App { + if router == nil { + panic("go-omni: nil router") + } + return newApp(router, configure(options...)) +} + +func configure(options ...config.Option) config.Config { + settings := config.Default() + for _, option := range options { + if option != nil { + option(&settings) + } + } + return settings +} + +func newApp(router httpx.Router, settings config.Config) *App { + middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)} + if settings.EnableLogger { + middlewares = append(middlewares, middleware.AccessLog()) + } + middlewares = append(middlewares, middleware.Recovery()) + return &App{router: router, config: settings, middlewares: middlewares} +} + +// Router returns the transport contract used by this application. +func (a *App) Router() httpx.Router { return a.router } + +// Engine is kept as a compatibility name for the old API. It returns a +// standard http.Handler, so callers no longer need a Gin type. +func (a *App) Engine() http.Handler { return a.router } + +// ServeHTTP implements http.Handler for standard servers and HTTP tests. +func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + a.router.ServeHTTP(writer, request) +} + +// Config returns the effective application configuration. +func (a *App) Config() config.Config { return a.config } + +// Use adds middleware to routes registered after this call. +func (a *App) Use(middleware ...httpx.Middleware) *App { + a.middlewares = append(a.middlewares, middleware...) + return a +} + +// OnShutdown registers a cleanup function owned by the application. Cleanup +// functions run in reverse registration order when Close or RunContext ends. +// This keeps omni.New as the only public startup entry point while allowing +// projects to attach databases, loggers and other resources to its lifecycle. +func (a *App) OnShutdown(cleanup func() error) *App { + if cleanup != nil { + a.closers = append(a.closers, cleanup) + } + return a +} + +// Close releases application resources. It is safe to call more than once. +func (a *App) Close() error { + a.closeOnce.Do(func() { + for index := len(a.closers) - 1; index >= 0; index-- { + if err := a.closers[index](); err != nil { + a.closeErr = errors.Join(a.closeErr, err) + } + } + }) + return a.closeErr +} + +// Run starts the HTTP server. The default address is :8080. +func (a *App) Run(address ...string) error { + return a.RunContext(context.Background(), address...) +} + +// NewServer creates a standard HTTP server around the application. +func (a *App) NewServer(address string) *http.Server { + if address == "" { + address = a.config.Address + if address == "" { + address = ":8080" + } + } + return &http.Server{Addr: address, Handler: a.router} +} + +// RunContext serves until the context is canceled, then performs a graceful shutdown. +func (a *App) RunContext(ctx context.Context, address ...string) error { + if ctx == nil { + ctx = context.Background() + } + listenOn := a.config.Address + if listenOn == "" { + listenOn = ":8080" + } + if len(address) > 0 && address[0] != "" { + listenOn = address[0] + } + server := a.NewServer(listenOn) + serveErr := make(chan error, 1) + go func() { serveErr <- server.ListenAndServe() }() + + select { + case err := <-serveErr: + closeErr := a.Close() + if err == http.ErrServerClosed { + return closeErr + } + return errors.Join(err, closeErr) + case <-ctx.Done(): + shutdownTimeout := a.config.ShutdownTimeout + if shutdownTimeout <= 0 { + shutdownTimeout = 10 * time.Second + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + shutdownErr := server.Shutdown(shutdownCtx) + if err := <-serveErr; err != nil && err != http.ErrServerClosed { + shutdownErr = errors.Join(shutdownErr, err) + } + return errors.Join(shutdownErr, a.Close()) + } +} + +func (a *App) writeError(ctx *httpx.Context, err error) { + if err == nil || ctx.Responded() { + return + } + ctx.WriteError(apierrors.From(err)) +} diff --git a/app/router.go b/app/router.go new file mode 100644 index 0000000..f5e2f66 --- /dev/null +++ b/app/router.go @@ -0,0 +1,107 @@ +package app + +import ( + "net/http" + + "git.yuntuo.hk/k2safe/go-omni/httpx" +) + +func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) { + routes.Handle(method, path, a.adapt(handler, middleware...)) +} + +func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler { + wrapped := handler + all := make([]httpx.Middleware, 0, len(a.middlewares)+len(middleware)) + all = append(all, a.middlewares...) + all = append(all, middleware...) + for index := len(all) - 1; index >= 0; index-- { + if all[index] != nil { + wrapped = all[index](wrapped) + } + } + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context())) + if err := wrapped(requestContext); err != nil { + a.writeError(requestContext, err) + } + }) +} + +func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + a.handle(a.router, http.MethodGet, path, handler, middleware) +} + +func (a *App) POST(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + a.handle(a.router, http.MethodPost, path, handler, middleware) +} + +func (a *App) PUT(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + a.handle(a.router, http.MethodPut, path, handler, middleware) +} + +func (a *App) PATCH(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + a.handle(a.router, http.MethodPatch, path, handler, middleware) +} + +func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + a.handle(a.router, http.MethodDelete, path, handler, middleware) +} + +func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + a.router.Any(path, a.adapt(handler, middleware...)) +} + +// RouterGroup scopes a route prefix and optional middleware. +type RouterGroup struct { + group httpx.RouterGroup + app *App + middlewares []httpx.Middleware +} + +func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup { + return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware} +} + +func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup { + g.middlewares = append(g.middlewares, middleware...) + return g +} + +func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup { + combined := append([]httpx.Middleware{}, g.middlewares...) + combined = append(combined, middleware...) + return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined} +} + +func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) { + combined := append([]httpx.Middleware{}, g.middlewares...) + combined = append(combined, middleware...) + g.app.handle(g.group, method, path, handler, combined) +} + +func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + g.route(http.MethodGet, path, handler, middleware) +} + +func (g *RouterGroup) POST(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + g.route(http.MethodPost, path, handler, middleware) +} + +func (g *RouterGroup) PUT(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + g.route(http.MethodPut, path, handler, middleware) +} + +func (g *RouterGroup) PATCH(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + g.route(http.MethodPatch, path, handler, middleware) +} + +func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + g.route(http.MethodDelete, path, handler, middleware) +} + +func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) { + combined := append([]httpx.Middleware{}, g.middlewares...) + combined = append(combined, middleware...) + g.group.Any(path, g.app.adapt(handler, combined...)) +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..62d284b --- /dev/null +++ b/app_test.go @@ -0,0 +1,132 @@ +package omni + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestAppReturnsStandardResponse(t *testing.T) { + app := New(WithMode(gin.TestMode)) + app.GET("/health", func(ctx *Context) error { + return ctx.OK(map[string]string{"status": "ok"}) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/health", nil) + app.Engine().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", recorder.Code) + } + var response Response + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Code != "OK" || response.Message != "success" { + t.Fatalf("unexpected response: %+v", response) + } + if recorder.Header().Get("X-Request-ID") == "" || response.RequestID == "" { + t.Fatal("expected request ID in header and response") + } + if !strings.Contains(recorder.Body.String(), `"requestId"`) || strings.Contains(recorder.Body.String(), `"request_id"`) { + t.Fatalf("expected lowerCamelCase response fields: %s", recorder.Body.String()) + } +} + +func TestAppReturnsAPIError(t *testing.T) { + app := New(WithMode(gin.TestMode)) + app.GET("/users/:id", func(ctx *Context) error { + return NotFound("USER_NOT_FOUND", "user was not found") + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/users/1", nil) + app.Engine().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", recorder.Code) + } + var response Response + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Code != "USER_NOT_FOUND" || response.Message != "user was not found" { + t.Fatalf("unexpected response: %+v", response) + } +} + +func TestAppRecoversFromPanic(t *testing.T) { + app := New(WithMode(gin.TestMode)) + app.GET("/panic", func(ctx *Context) error { + panic("boom") + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/panic", nil) + app.Engine().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", recorder.Code) + } + var response Response + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Code != "INTERNAL_ERROR" { + t.Fatalf("unexpected response: %+v", response) + } +} + +func TestAppRunContextShutsDownGracefully(t *testing.T) { + app := New(WithMode(gin.TestMode), WithShutdownTimeout(time.Second)) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- app.RunContext(ctx, "127.0.0.1:0") }() + time.Sleep(50 * time.Millisecond) + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("graceful shutdown returned an error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("server did not stop after context cancellation") + } +} + +func TestAppClosesResourcesInReverseOrder(t *testing.T) { + app := New(WithMode(gin.TestMode)) + var closed []string + app.OnShutdown(func() error { + closed = append(closed, "database") + return nil + }).OnShutdown(func() error { + closed = append(closed, "logger") + return nil + }) + + if err := app.Close(); err != nil { + t.Fatalf("close app: %v", err) + } + if err := app.Close(); err != nil { + t.Fatalf("close app twice: %v", err) + } + if got := strings.Join(closed, ","); got != "logger,database" { + t.Fatalf("unexpected close order: %s", got) + } +} + +func TestAppUsesConfiguredAddressWhenNoOverrideIsProvided(t *testing.T) { + settings := Config{Address: "127.0.0.1:18080", RequestIDHeader: "X-Request-ID", ShutdownTimeout: time.Second} + app := New(WithConfig(settings)) + if got := app.NewServer("").Addr; got != settings.Address { + t.Fatalf("expected configured address %q, got %q", settings.Address, got) + } +} diff --git a/auth/README.md b/auth/README.md new file mode 100644 index 0000000..176b2e4 --- /dev/null +++ b/auth/README.md @@ -0,0 +1,36 @@ +# auth + +`auth` 只定义身份和权限边界,不把 JWT、Session、OAuth 或某个用户表绑定到核心框架。 + +## 核心用法 + +认证器把外部凭据转换成通用身份: + +```go +type Authenticator interface { + Authenticate(context.Context, string) (Principal, error) +} +``` + +把认证放在路由组上: + +```go +private := app.Group("/api", auth.RequireAuth(authenticator)) +private.GET("/me", getMe) + +admin := private.Group("/admin", auth.RequireRole("admin")) +admin.POST("/users", createUser) +``` + +Handler 可以读取身份,但不需要知道 Token 的格式: + +```go +principal, ok := auth.FromContext(ctx) +if !ok { + return omni.Unauthorized("UNAUTHENTICATED", "authentication is required") +} +``` + +认证失败统一返回 `401 UNAUTHENTICATED`,身份存在但角色不满足统一返回 `403 FORBIDDEN`。无效 Token 的详细原因保留在错误链中,不出现在 HTTP 响应里。 + +`StaticAuthenticator` 只用于本地开发和测试,不能作为生产认证方案。生产项目实现 `Authenticator`,在实现内部校验 JWT、Session 或其他凭据,并返回 `Principal`。 diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..20d96ef --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,145 @@ +// Package auth defines authentication and authorization boundaries for GoOmni. +// It does not choose JWT, sessions, OAuth, or any other credential format. +package auth + +import ( + "context" + "errors" + "strings" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" +) + +const principalKey = "go-omni.auth.principal" + +var ( + ErrMissingToken = errors.New("authorization token is missing") + ErrInvalidToken = errors.New("authorization token is invalid") +) + +// Principal is the authenticated identity made available to business handlers. +// Claims are optional provider-specific values and should not be required by core business code. +type Principal struct { + UserID string + Roles []string + Claims map[string]any +} + +func (p Principal) HasRole(role string) bool { + role = strings.TrimSpace(role) + if role == "" { + return false + } + for _, candidate := range p.Roles { + if candidate == role { + return true + } + } + return false +} + +// Authenticator converts a credential into a provider-neutral Principal. +type Authenticator interface { + Authenticate(context.Context, string) (Principal, error) +} + +// StaticAuthenticator is intended only for local development and tests. +type StaticAuthenticator map[string]Principal + +func (a StaticAuthenticator) Authenticate(_ context.Context, token string) (Principal, error) { + principal, ok := a[token] + if !ok { + return Principal{}, ErrInvalidToken + } + return clonePrincipal(principal), nil +} + +// FromContext returns the authenticated identity attached by RequireAuth. +func FromContext(ctx *httpx.Context) (Principal, bool) { + value, ok := ctx.Get(principalKey) + if !ok { + return Principal{}, false + } + principal, ok := value.(Principal) + return principal, ok +} + +// RequireAuth extracts a Bearer token and attaches its Principal to the request. +func RequireAuth(authenticator Authenticator) httpx.Middleware { + return func(next httpx.Handler) httpx.Handler { + return func(ctx *httpx.Context) error { + if authenticator == nil { + return apierrors.Internal(errors.New("authenticator is not configured")) + } + token, err := bearerToken(ctx.Header("Authorization")) + if err != nil { + return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err) + } + principal, err := authenticator.Authenticate(ctx.Request().Context(), token) + if err != nil || strings.TrimSpace(principal.UserID) == "" { + if err == nil { + err = ErrInvalidToken + } + return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required").WithCause(err) + } + ctx.Set(principalKey, clonePrincipal(principal)) + return next(ctx) + } + } +} + +// OptionalAuth attaches a Principal when a valid Bearer token is present. +// Missing credentials are allowed; malformed or invalid credentials are rejected. +func OptionalAuth(authenticator Authenticator) httpx.Middleware { + return func(next httpx.Handler) httpx.Handler { + return func(ctx *httpx.Context) error { + if strings.TrimSpace(ctx.Header("Authorization")) == "" { + return next(ctx) + } + return RequireAuth(authenticator)(next)(ctx) + } + } +} + +// RequireRole checks a Principal attached by RequireAuth. +func RequireRole(roles ...string) httpx.Middleware { + return func(next httpx.Handler) httpx.Handler { + return func(ctx *httpx.Context) error { + principal, ok := FromContext(ctx) + if !ok { + return apierrors.Unauthorized("UNAUTHENTICATED", "authentication is required") + } + for _, role := range roles { + if principal.HasRole(role) { + return next(ctx) + } + } + return apierrors.Forbidden("FORBIDDEN", "permission denied") + } + } +} + +func bearerToken(value string) (string, error) { + parts := strings.Fields(value) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" { + return "", ErrMissingToken + } + return parts[1], nil +} + +func clonePrincipal(principal Principal) Principal { + principal.Roles = append([]string(nil), principal.Roles...) + if principal.Claims != nil { + principal.Claims = mapsClone(principal.Claims) + } + return principal +} + +func mapsClone(values map[string]any) map[string]any { + clone := make(map[string]any, len(values)) + for key, value := range values { + clone[key] = value + } + return clone +} diff --git a/auth/auth_test.go b/auth/auth_test.go new file mode 100644 index 0000000..bc7147e --- /dev/null +++ b/auth/auth_test.go @@ -0,0 +1,122 @@ +package auth_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/auth" + "github.com/gin-gonic/gin" +) + +func TestRequireAuthAndRole(t *testing.T) { + app := omni.New(omni.WithMode(gin.TestMode)) + authenticator := auth.StaticAuthenticator{"good-token": {UserID: "user-1", Roles: []string{"user"}}} + private := app.Group("/private", auth.RequireAuth(authenticator)) + private.GET("/me", func(ctx *omni.Context) error { + principal, ok := auth.FromContext(ctx) + if !ok { + t.Fatal("principal was not attached") + } + return ctx.OK(principal) + }) + private.GET("/admin", func(ctx *omni.Context) error { + return ctx.OK(map[string]string{"ok": "true"}) + }, auth.RequireRole("admin")) + + tests := []struct { + name, authorization, path, code string + status int + }{ + {"missing", "", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized}, + {"malformed", "Token good-token", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized}, + {"invalid", "Bearer bad-token", "/private/me", "UNAUTHENTICATED", http.StatusUnauthorized}, + {"valid", "Bearer good-token", "/private/me", "OK", http.StatusOK}, + {"forbidden", "Bearer good-token", "/private/admin", "FORBIDDEN", http.StatusForbidden}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, test.path, nil) + if test.authorization != "" { + request.Header.Set("Authorization", test.authorization) + } + app.ServeHTTP(recorder, request) + if recorder.Code != test.status { + t.Fatalf("expected status %d, got %d: %s", test.status, recorder.Code, recorder.Body.String()) + } + var body struct { + Code string `json:"code"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != test.code { + t.Fatalf("expected code %q, got %q", test.code, body.Code) + } + }) + } +} + +func TestOptionalAuth(t *testing.T) { + app := omni.New(omni.WithMode(gin.TestMode)) + app.GET("/public", func(ctx *omni.Context) error { + principal, authenticated := auth.FromContext(ctx) + return ctx.OK(map[string]any{"authenticated": authenticated, "userId": principal.UserID}) + }, auth.OptionalAuth(auth.StaticAuthenticator{"token": {UserID: "user-1"}})) + + for _, test := range []struct { + name, token string + wantAuth bool + status int + }{ + {"anonymous", "", false, http.StatusOK}, + {"authenticated", "Bearer token", true, http.StatusOK}, + {"invalid", "Bearer bad", false, http.StatusUnauthorized}, + } { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/public", nil) + if test.token != "" { + request.Header.Set("Authorization", test.token) + } + app.ServeHTTP(recorder, request) + if recorder.Code != test.status { + t.Fatalf("expected status %d, got %d", test.status, recorder.Code) + } + if test.wantAuth { + var body struct { + Data struct { + Authenticated bool `json:"authenticated"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.Data.Authenticated { + t.Fatal("expected authenticated response") + } + } + }) + } +} + +func TestStaticAuthenticatorDoesNotExposeMutableSlices(t *testing.T) { + authenticator := auth.StaticAuthenticator{"token": {UserID: "user-1", Roles: []string{"admin"}, Claims: map[string]any{"tenant": "a"}}} + principal, err := authenticator.Authenticate(context.Background(), "token") + if err != nil { + t.Fatal(err) + } + principal.Roles[0] = "changed" + principal.Claims["tenant"] = "changed" + again, err := authenticator.Authenticate(context.Background(), "token") + if err != nil { + t.Fatal(err) + } + if !again.HasRole("admin") || again.Claims["tenant"] != "a" { + t.Fatalf("static authenticator returned mutable state: %+v", again) + } +} diff --git a/cmd/README.md b/cmd/README.md new file mode 100644 index 0000000..fd62b5d --- /dev/null +++ b/cmd/README.md @@ -0,0 +1,3 @@ +# cmd + +命令行工具入口。目前提供 `go-omni new`,用于生成 Handler / Service / Repository / Model 分层的服务骨架。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md new file mode 100644 index 0000000..af64a07 --- /dev/null +++ b/cmd/go-omni/README.md @@ -0,0 +1,11 @@ +# go-omni CLI + +生成一个带 Handler / Service / Repository / Model 分层的服务骨架: + +```sh +go run ./cmd/go-omni new --module example.com/billing ./billing +``` + +在 GoOmni 源码目录执行时,命令会自动在生成项目的 `go.mod` 中加入本地 +`replace git.yuntuo.hk/k2safe/go-omni => ...`,便于立即运行和测试。目标目录非空时命令会停止,明确传入 +`--force` 才会覆盖脚手架文件。 diff --git a/cmd/go-omni/main.go b/cmd/go-omni/main.go new file mode 100644 index 0000000..31f2643 --- /dev/null +++ b/cmd/go-omni/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + switch os.Args[1] { + case "new": + if err := runNew(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "go-omni new:", err) + os.Exit(1) + } + case "help", "-h", "--help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1]) + usage() + os.Exit(2) + } +} + +func runNew(args []string) error { + options := scaffold.Options{} + var target string + for index := 0; index < len(args); index++ { + arg := args[index] + switch { + case arg == "--force": + options.Force = true + case arg == "--module": + if index+1 >= len(args) { + return errors.New("--module requires a value") + } + index++ + options.Module = args[index] + case strings.HasPrefix(arg, "--module="): + options.Module = strings.TrimPrefix(arg, "--module=") + case arg == "--framework-dir": + if index+1 >= len(args) { + return errors.New("--framework-dir requires a value") + } + index++ + options.FrameworkDir = args[index] + case strings.HasPrefix(arg, "--framework-dir="): + options.FrameworkDir = strings.TrimPrefix(arg, "--framework-dir=") + case strings.HasPrefix(arg, "-"): + return fmt.Errorf("unknown option %q", arg) + case target == "": + target = arg + default: + return fmt.Errorf("unexpected argument %q", arg) + } + } + if target == "" { + return errors.New("project directory is required") + } + if options.Module == "" { + options.Module = filepath.Base(filepath.Clean(target)) + } + options.Dir = target + if err := scaffold.Generate(options); err != nil { + return err + } + fmt.Printf("created GoOmni project %s\n", filepath.Clean(target)) + return nil +} + +func usage() { + fmt.Fprintln(os.Stderr, "GoOmni project tool") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " go-omni new [--module ] [--force] ") +} diff --git a/cmd/go-omni/scaffold/scaffold.go b/cmd/go-omni/scaffold/scaffold.go new file mode 100644 index 0000000..1c4405a --- /dev/null +++ b/cmd/go-omni/scaffold/scaffold.go @@ -0,0 +1,184 @@ +package scaffold + +import ( + "embed" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "text/template" +) + +//go:embed templates +var templateFiles embed.FS + +// Options controls project generation. +type Options struct { + Dir string + Module string + FrameworkDir string + Force bool +} + +var files = map[string]string{ + "go.mod": "templates/go.mod.tmpl", + "README.md": "templates/README.md.tmpl", + "cmd/server/main.go": "templates/cmd-server-main.go.tmpl", + "internal/model/user.go": "templates/internal-model-user.go.tmpl", + "internal/repository/memory.go": "templates/internal-repository-memory.go.tmpl", + "internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl", + "internal/service/user.go": "templates/internal-service-user.go.tmpl", + "internal/handler/user.go": "templates/internal-handler-user.go.tmpl", +} + +// Generate creates a GoOmni service skeleton. +func Generate(options Options) error { + if strings.TrimSpace(options.Dir) == "" { + return errors.New("project directory is required") + } + if strings.TrimSpace(options.Module) == "" { + return errors.New("module path is required") + } + + directory, err := filepath.Abs(options.Dir) + if err != nil { + return fmt.Errorf("resolve project directory: %w", err) + } + if err := checkTarget(directory, options.Force); err != nil { + return err + } + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("create project directory: %w", err) + } + + data := templateData{ + Module: options.Module, + FrameworkReplace: frameworkReplace(directory, options.FrameworkDir), + } + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + content, err := render(files[path], data) + if err != nil { + return fmt.Errorf("render %s: %w", path, err) + } + target := filepath.Join(directory, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(path), err) + } + if !options.Force { + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("file already exists: %s", target) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("check %s: %w", target, err) + } + } + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", target, err) + } + } + return nil +} + +type templateData struct { + Module string + FrameworkReplace string +} + +func render(name string, data templateData) (string, error) { + content, err := fs.ReadFile(templateFiles, name) + if err != nil { + return "", err + } + tmpl, err := newTemplate(name, string(content)) + if err != nil { + return "", err + } + var output strings.Builder + if err := tmpl.Execute(&output, data); err != nil { + return "", err + } + return output.String(), nil +} + +func newTemplate(name, content string) (*template.Template, error) { + return template.New(name).Parse(content) +} + +func checkTarget(directory string, force bool) error { + info, err := os.Stat(directory) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("check project directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("project path is not a directory: %s", directory) + } + if force { + return nil + } + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("read project directory: %w", err) + } + if len(entries) > 0 { + return fmt.Errorf("project directory is not empty: %s (use --force to overwrite generated files)", directory) + } + return nil +} + +func frameworkReplace(projectDir, configured string) string { + root := strings.TrimSpace(configured) + if root == "" { + root = findFrameworkRoot() + } + if root == "" { + return "" + } + projectDir = canonicalPath(projectDir) + absoluteRoot := canonicalPath(root) + relative, err := filepath.Rel(projectDir, absoluteRoot) + if err != nil { + return "" + } + return filepath.ToSlash(relative) +} + +func canonicalPath(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return path + } + resolved, err := filepath.EvalSymlinks(abs) + if err == nil { + return resolved + } + return abs +} + +func findFrameworkRoot() string { + directory, err := os.Getwd() + if err != nil { + return "" + } + for { + moduleFile := filepath.Join(directory, "go.mod") + content, readErr := os.ReadFile(moduleFile) + if readErr == nil && strings.Contains(string(content), "module git.yuntuo.hk/k2safe/go-omni") { + return directory + } + parent := filepath.Dir(directory) + if parent == directory { + return "" + } + directory = parent + } +} diff --git a/cmd/go-omni/scaffold/scaffold_test.go b/cmd/go-omni/scaffold/scaffold_test.go new file mode 100644 index 0000000..b1506db --- /dev/null +++ b/cmd/go-omni/scaffold/scaffold_test.go @@ -0,0 +1,97 @@ +package scaffold + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateCreatesServiceLayout(t *testing.T) { + directory := t.TempDir() + project := filepath.Join(directory, "billing") + if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkDir: frameworkRoot(t)}); err != nil { + t.Fatalf("generate project: %v", err) + } + for _, path := range []string{ + "go.mod", + "cmd/server/main.go", + "internal/model/user.go", + "internal/repository/memory.go", + "internal/servicecontext/context.go", + "internal/service/user.go", + "internal/handler/user.go", + } { + if _, err := os.Stat(filepath.Join(project, filepath.FromSlash(path))); err != nil { + t.Fatalf("expected generated file %s: %v", path, err) + } + } + content, err := os.ReadFile(filepath.Join(project, "go.mod")) + if err != nil { + t.Fatalf("read generated go.mod: %v", err) + } + if !strings.Contains(string(content), "replace git.yuntuo.hk/k2safe/go-omni =>") { + t.Fatalf("expected local framework replace directive: %s", content) + } + mainContent, err := os.ReadFile(filepath.Join(project, "cmd/server/main.go")) + if err != nil { + t.Fatalf("read generated server: %v", err) + } + for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(logger, users)", "health.New()", "application.Run(settings.Address)"} { + if !strings.Contains(string(mainContent), expected) { + t.Fatalf("generated server missing %q: %s", expected, mainContent) + } + } +} + +func TestGenerateRejectsNonEmptyDirectory(t *testing.T) { + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, "existing.txt"), []byte("keep"), 0o644); err != nil { + t.Fatalf("seed directory: %v", err) + } + if err := Generate(Options{Dir: directory, Module: "example.com/demo"}); err == nil { + t.Fatal("expected non-empty directory error") + } +} + +func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) { + root := frameworkRoot(t) + project := filepath.Join(t.TempDir(), "generated") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + replace := frameworkReplace(project, root) + resolvedProject, err := filepath.EvalSymlinks(project) + if err != nil { + t.Fatal(err) + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + expected, err := filepath.Rel(resolvedProject, resolvedRoot) + if err != nil { + t.Fatal(err) + } + if replace != filepath.ToSlash(expected) { + t.Fatalf("expected canonical replace %q, got %q", filepath.ToSlash(expected), replace) + } +} + +func frameworkRoot(t *testing.T) string { + t.Helper() + directory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil { + return directory + } + parent := filepath.Dir(directory) + if parent == directory { + t.Fatal("framework root not found") + } + directory = parent + } +} diff --git a/cmd/go-omni/scaffold/templates/README.md.tmpl b/cmd/go-omni/scaffold/templates/README.md.tmpl new file mode 100644 index 0000000..a3423ce --- /dev/null +++ b/cmd/go-omni/scaffold/templates/README.md.tmpl @@ -0,0 +1,22 @@ +# GoOmni service + +This service was generated with `go-omni new`. + +```sh +go mod tidy +go run ./cmd/server +``` + +运行时配置从 `GO_OMNI_*` 环境变量加载,例如: + +```sh +GO_OMNI_ENABLE_LOGGER=true GO_OMNI_LOG_LEVEL=debug go run ./cmd/server +``` + +`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和 +`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。 + +The generated layout keeps HTTP handlers, business services, repositories, +models and a project-owned ServiceContext separate. Replace the memory +repository with a database implementation when the service is ready for +persistence, then add it to `internal/servicecontext`. diff --git a/cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl b/cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl new file mode 100644 index 0000000..93082c2 --- /dev/null +++ b/cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl @@ -0,0 +1,49 @@ +package main + +import ( + "log" + "strings" + + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/health" + "git.yuntuo.hk/k2safe/go-omni/logx" + "{{.Module}}/internal/handler" + "{{.Module}}/internal/repository" + "{{.Module}}/internal/service" + "{{.Module}}/internal/servicecontext" +) + +func main() { + settings, err := config.LoadFromEnv("") + if err != nil { + log.Fatal(err) + } + if strings.TrimSpace(settings.Log.Service) == "" { + settings.Log.Service = "{{.Module}}" + } + logger, err := logx.New(settings.Log) + if err != nil { + log.Fatal(err) + } + // Access logs and business logs share the same configured sink. + logx.SetDefault(logger) + + users := repository.NewMemoryUsers() + services := servicecontext.New(logger, users) + userService := service.NewUsers(services) + userHandler := handler.NewUsers(userService) + checker, err := health.New() + if err != nil { + log.Fatal(err) + } + + application := omni.New(omni.WithConfig(settings)) + application.OnShutdown(logger.Close) + application.GET("/health/live", checker.LivenessHandler()) + application.GET("/health/ready", checker.ReadinessHandler()) + userHandler.RegisterRoutes(application) + if err := application.Run(settings.Address); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/go-omni/scaffold/templates/go.mod.tmpl b/cmd/go-omni/scaffold/templates/go.mod.tmpl new file mode 100644 index 0000000..6d110ca --- /dev/null +++ b/cmd/go-omni/scaffold/templates/go.mod.tmpl @@ -0,0 +1,8 @@ +module {{.Module}} + +go 1.22 + +require git.yuntuo.hk/k2safe/go-omni v0.0.0 +{{- if .FrameworkReplace}} +replace git.yuntuo.hk/k2safe/go-omni => {{.FrameworkReplace}} +{{- end}} diff --git a/cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl b/cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl new file mode 100644 index 0000000..5c2477e --- /dev/null +++ b/cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl @@ -0,0 +1,31 @@ +package handler + +import ( + "net/http" + + omni "git.yuntuo.hk/k2safe/go-omni" + "{{.Module}}/internal/model" + "{{.Module}}/internal/service" +) + +type Users struct { + service *service.Users +} + +func NewUsers(service *service.Users) *Users { return &Users{service: service} } + +func (h *Users) RegisterRoutes(application *omni.App) { + application.POST("/users", h.Create) +} + +func (h *Users) Create(ctx *omni.Context) error { + var input model.UserInput + if err := ctx.BindJSON(&input); err != nil { + return err + } + user, err := h.service.Create(ctx.Context(), input) + if err != nil { + return err + } + return ctx.JSON(http.StatusCreated, user) +} diff --git a/cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl b/cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl new file mode 100644 index 0000000..f810df0 --- /dev/null +++ b/cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl @@ -0,0 +1,16 @@ +package model + +import "time" + +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserInput struct { + Name string `json:"name"` + Email string `json:"email"` +} diff --git a/cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl b/cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl new file mode 100644 index 0000000..06d8f1f --- /dev/null +++ b/cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + "sync" + "time" + + "{{.Module}}/internal/model" +) + +type UserRepository interface { + Create(context.Context, model.UserInput) (model.User, error) +} + +type MemoryUsers struct { + mu sync.Mutex + nextID int64 + users []model.User +} + +func NewMemoryUsers() *MemoryUsers { return &MemoryUsers{nextID: 1} } + +func (r *MemoryUsers) Create(_ context.Context, input model.UserInput) (model.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now().UTC() + user := model.User{ID: r.nextID, Name: input.Name, Email: input.Email, CreatedAt: now, UpdatedAt: now} + r.nextID++ + r.users = append(r.users, user) + return user, nil +} diff --git a/cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl b/cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl new file mode 100644 index 0000000..6cdcb52 --- /dev/null +++ b/cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl @@ -0,0 +1,21 @@ +package service + +import ( + "context" + + "{{.Module}}/internal/model" + "{{.Module}}/internal/servicecontext" +) + +type Users struct { + context *servicecontext.Context +} + +func NewUsers(context *servicecontext.Context) *Users { + return &Users{context: context} +} + +func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) { + // Put business normalization and rules here. + return s.context.Users.Create(ctx, input) +} diff --git a/cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl b/cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl new file mode 100644 index 0000000..a64b1b0 --- /dev/null +++ b/cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl @@ -0,0 +1,17 @@ +package servicecontext + +import ( + "git.yuntuo.hk/k2safe/go-omni/logx" + "{{.Module}}/internal/repository" +) + +// Context owns infrastructure dependencies shared by the service layer. +// Add database, cache and external clients here as the project grows. +type Context struct { + Logger *logx.Logger + Users repository.UserRepository +} + +func New(logger *logx.Logger, users repository.UserRepository) *Context { + return &Context{Logger: logger, Users: users} +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..008e8a5 --- /dev/null +++ b/config/config.go @@ -0,0 +1,93 @@ +package config + +import ( + "strings" + "time" + + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +const defaultRequestIDHeader = "X-Request-ID" + +// Config controls the default runtime behavior of an App. +type Config struct { + Mode string + Address string + RequestIDHeader string + EnableLogger bool + ShutdownTimeout time.Duration + Log logx.Config +} + +func Default() Config { + return Config{ + Address: ":8080", + RequestIDHeader: defaultRequestIDHeader, + ShutdownTimeout: 10 * time.Second, + Log: logx.Default(), + } +} + +// Validate checks configuration values before an application starts. +func (c Config) Validate() error { + if strings.TrimSpace(c.Address) == "" { + return &ConfigError{Field: "address", Message: "is required"} + } + if strings.TrimSpace(c.RequestIDHeader) == "" { + return &ConfigError{Field: "request_id_header", Message: "is required"} + } + if c.ShutdownTimeout <= 0 { + return &ConfigError{Field: "shutdown_timeout", Message: "must be positive"} + } + return nil +} + +// ConfigError describes an invalid application configuration field. +type ConfigError struct { + Field string + Message string +} + +func (e *ConfigError) Error() string { return "config " + e.Field + " " + e.Message } + +// Option changes an App's configuration. +type Option func(*Config) + +// WithMode sets the default transport's running mode. +func WithMode(mode string) Option { + return func(cfg *Config) { cfg.Mode = mode } +} + +// WithRequestIDHeader changes the header used to read and return request IDs. +func WithRequestIDHeader(header string) Option { + return func(cfg *Config) { + if value := strings.TrimSpace(header); value != "" { + cfg.RequestIDHeader = value + } + } +} + +// WithLogger enables GoOmni's structured access logger. +func WithLogger(enabled bool) Option { + return func(cfg *Config) { cfg.EnableLogger = enabled } +} + +// WithConfig applies a complete configuration loaded from a file or +// environment. Later options can still override individual fields. +func WithConfig(settings Config) Option { + return func(cfg *Config) { *cfg = settings } +} + +// WithLogConfig configures the logger used by the application runtime. +func WithLogConfig(settings logx.Config) Option { + return func(cfg *Config) { cfg.Log = settings } +} + +// WithShutdownTimeout controls how long RunContext waits for active requests. +func WithShutdownTimeout(timeout time.Duration) Option { + return func(cfg *Config) { + if timeout > 0 { + cfg.ShutdownTimeout = timeout + } + } +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..04b534c --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,95 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +func TestLoadFromEnvUsesDefaults(t *testing.T) { + for _, key := range []string{ + "GO_OMNI_MODE", "GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER", + "GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT", + "GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT", + "GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT", + } { + t.Setenv(key, "") + } + + cfg, err := LoadFromEnv("") + if err != nil { + t.Fatal(err) + } + if cfg.Address != ":8080" || cfg.RequestIDHeader != "X-Request-ID" || cfg.ShutdownTimeout != 10*time.Second { + t.Fatalf("unexpected defaults: %+v", cfg) + } + if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" { + t.Fatalf("unexpected logger defaults: %+v", cfg.Log) + } +} + +func TestLoadFromEnvOverridesSettings(t *testing.T) { + t.Setenv("GO_OMNI_MODE", "release") + t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090") + t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID") + t.Setenv("GO_OMNI_ENABLE_LOGGER", "true") + t.Setenv("GO_OMNI_LOG_LEVEL", "debug") + t.Setenv("GO_OMNI_LOG_FORMAT", "text") + t.Setenv("GO_OMNI_LOG_OUTPUT", "stdout") + t.Setenv("GO_OMNI_SERVICE", "billing") + t.Setenv("GO_OMNI_ENVIRONMENT", "test") + t.Setenv("GO_OMNI_LOG_ADD_SOURCE", "true") + t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "3s") + + cfg, err := LoadFromEnv("") + if err != nil { + t.Fatal(err) + } + if cfg.Mode != "release" || cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger { + t.Fatalf("unexpected application settings: %+v", cfg) + } + if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource { + t.Fatalf("unexpected logger settings: %+v", cfg.Log) + } +} + +func TestLoadFromEnvRejectsInvalidValues(t *testing.T) { + t.Setenv("GO_OMNI_ENABLE_LOGGER", "sometimes") + if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "ENABLE_LOGGER") { + t.Fatalf("expected invalid logger flag error, got %v", err) + } + + t.Setenv("GO_OMNI_ENABLE_LOGGER", "") + t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "later") + if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "SHUTDOWN_TIMEOUT") { + t.Fatalf("expected invalid timeout error, got %v", err) + } + + t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s") + cfg := Default() + cfg.Address = "" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") { + t.Fatalf("expected invalid address error, got %v", err) + } + cfg = Default() + cfg.RequestIDHeader = " " + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "request_id_header") { + t.Fatalf("expected invalid request header error, got %v", err) + } +} + +func TestWithConfigAndWithLogConfig(t *testing.T) { + settings := Default() + settings.Mode = "release" + settings.Log.Service = "orders" + cfg := Default() + WithConfig(settings)(&cfg) + if cfg.Mode != "release" || cfg.Log.Service != "orders" { + t.Fatalf("complete config was not applied: %+v", cfg) + } + + WithLogConfig(Default().Log)(&cfg) + if cfg.Log.Service != "" { + t.Fatalf("log config was not replaced: %+v", cfg.Log) + } +} diff --git a/config/env.go b/config/env.go new file mode 100644 index 0000000..73f9e66 --- /dev/null +++ b/config/env.go @@ -0,0 +1,67 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +const defaultEnvPrefix = "GO_OMNI_" + +// LoadFromEnv loads application settings from GO_OMNI_* variables. The +// database settings remain owned by database.ConfigFromEnv. +func LoadFromEnv(prefix string) (Config, error) { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = defaultEnvPrefix + } + cfg := Default() + cfg.Mode = envString(prefix+"MODE", cfg.Mode) + cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address) + cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader) + cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level) + cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format) + cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output) + cfg.Log.File = envString(prefix+"LOG_FILE", cfg.Log.File) + cfg.Log.Service = envString(prefix+"SERVICE", cfg.Log.Service) + cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment) + + var err error + if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil { + return Config{}, err + } + if cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); err != nil { + return Config{}, err + } + if value := strings.TrimSpace(os.Getenv(prefix + "SHUTDOWN_TIMEOUT")); value != "" { + cfg.ShutdownTimeout, err = time.ParseDuration(value) + if err != nil { + return Config{}, fmt.Errorf("%sSHUTDOWN_TIMEOUT must be a duration: %w", prefix, err) + } + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func envString(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func envBool(key string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s must be a boolean: %w", key, err) + } + return parsed, nil +} diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..985c026 --- /dev/null +++ b/database/README.md @@ -0,0 +1,37 @@ +# database + +`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动: + +- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。 +- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。 +- `Health`:用于就绪检查。 +- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。 +- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。 + +框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动: + +```go +import ( + "context" + _ "github.com/go-sql-driver/mysql" + "git.yuntuo.hk/k2safe/go-omni/database" +) + +cfg, err := database.ConfigFromEnv("GO_OMNI_DB_") +db, err := database.Open(context.Background(), cfg) +defer db.Close() +``` + +连接池相关环境变量: + +```text +GO_OMNI_DB_DRIVER=mysql +GO_OMNI_DB_DSN=user:password@tcp(127.0.0.1:3306)/app?parseTime=true +GO_OMNI_DB_MAX_OPEN_CONNS=10 +GO_OMNI_DB_MAX_IDLE_CONNS=5 +GO_OMNI_DB_CONN_MAX_LIFETIME=30m +GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m +GO_OMNI_DB_PING_TIMEOUT=5s +``` + +`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。 diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..95c5928 --- /dev/null +++ b/database/database.go @@ -0,0 +1,175 @@ +// Package database provides the database/sql lifecycle used by GoOmni projects. +// It deliberately does not import a database driver; each application chooses one. +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Driver string + DSN string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration + PingTimeout time.Duration +} + +func DefaultConfig() Config { + return Config{ + MaxOpenConns: 10, + MaxIdleConns: 5, + ConnMaxLifetime: 30 * time.Minute, + ConnMaxIdleTime: 5 * time.Minute, + PingTimeout: 5 * time.Second, + } +} + +// ConfigFromEnv reads GO_OMNI_DB_* style settings. DSN is required. +func ConfigFromEnv(prefix string) (Config, error) { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "GO_OMNI_DB_" + } + cfg := DefaultConfig() + cfg.Driver = strings.TrimSpace(os.Getenv(prefix + "DRIVER")) + if cfg.Driver == "" { + cfg.Driver = "mysql" + } + cfg.DSN = strings.TrimSpace(os.Getenv(prefix + "DSN")) + if cfg.DSN == "" { + return Config{}, fmt.Errorf("%sDSN is required", prefix) + } + var err error + if cfg.MaxOpenConns, err = envInt(prefix+"MAX_OPEN_CONNS", cfg.MaxOpenConns); err != nil { + return Config{}, err + } + if cfg.MaxIdleConns, err = envInt(prefix+"MAX_IDLE_CONNS", cfg.MaxIdleConns); err != nil { + return Config{}, err + } + if cfg.ConnMaxLifetime, err = envDuration(prefix+"CONN_MAX_LIFETIME", cfg.ConnMaxLifetime); err != nil { + return Config{}, err + } + if cfg.ConnMaxIdleTime, err = envDuration(prefix+"CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime); err != nil { + return Config{}, err + } + if cfg.PingTimeout, err = envDuration(prefix+"PING_TIMEOUT", cfg.PingTimeout); err != nil { + return Config{}, err + } + return cfg, cfg.Validate() +} + +func (c Config) Validate() error { + if strings.TrimSpace(c.Driver) == "" { + return errors.New("database driver is required") + } + if strings.TrimSpace(c.DSN) == "" { + return errors.New("database DSN is required") + } + if c.MaxOpenConns < 0 || c.MaxIdleConns < 0 { + return errors.New("database pool limits cannot be negative") + } + if c.MaxIdleConns > c.MaxOpenConns && c.MaxOpenConns > 0 { + return errors.New("max idle connections cannot exceed max open connections") + } + if c.ConnMaxLifetime < 0 || c.ConnMaxIdleTime < 0 || c.PingTimeout <= 0 { + return errors.New("database timeouts are invalid") + } + return nil +} + +// DB owns a connection pool and exposes the embedded database/sql methods. +type DB struct { + *sql.DB + config Config +} + +func Open(ctx context.Context, cfg Config) (*DB, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + db, err := sql.Open(cfg.Driver, cfg.DSN) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + if cfg.MaxOpenConns > 0 { + db.SetMaxOpenConns(cfg.MaxOpenConns) + } + if cfg.MaxIdleConns > 0 { + db.SetMaxIdleConns(cfg.MaxIdleConns) + } + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout) + defer cancel() + if err := db.PingContext(checkCtx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + return &DB{DB: db, config: cfg}, nil +} + +func (db *DB) Config() Config { return db.config } + +func (db *DB) Health(ctx context.Context) error { + if db == nil || db.DB == nil { + return errors.New("database is not initialized") + } + return db.PingContext(ctx) +} + +// InTx commits only when work returns nil. Any work error or panic rolls back. +func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) { + if db == nil || db.DB == nil { + return errors.New("database is not initialized") + } + tx, err := db.BeginTx(ctx, options) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { + rollbackErr := tx.Rollback() + if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr)) + } + }() + if err = work(tx); err != nil { + return err + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + +func envInt(key string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", key, err) + } + return parsed, nil +} + +func envDuration(key string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("%s must be a duration: %w", key, err) + } + return parsed, nil +} diff --git a/database/database_test.go b/database/database_test.go new file mode 100644 index 0000000..49dbbe6 --- /dev/null +++ b/database/database_test.go @@ -0,0 +1,58 @@ +package database + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestConfigFromEnv(t *testing.T) { + keys := []string{ + "GO_OMNI_DB_DRIVER", "GO_OMNI_DB_DSN", "GO_OMNI_DB_MAX_OPEN_CONNS", + "GO_OMNI_DB_MAX_IDLE_CONNS", "GO_OMNI_DB_CONN_MAX_LIFETIME", + "GO_OMNI_DB_CONN_MAX_IDLE_TIME", "GO_OMNI_DB_PING_TIMEOUT", + } + for _, key := range keys { + t.Setenv(key, "") + _ = os.Unsetenv(key) + } + t.Setenv("GO_OMNI_DB_DSN", "root:secret@tcp(127.0.0.1:3306)/demo") + t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "20") + t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "8") + t.Setenv("GO_OMNI_DB_CONN_MAX_LIFETIME", "2m") + t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "750ms") + cfg, err := ConfigFromEnv("") + if err != nil { + t.Fatal(err) + } + if cfg.Driver != "mysql" || cfg.DSN == "" || cfg.MaxOpenConns != 20 || cfg.MaxIdleConns != 8 || cfg.ConnMaxLifetime != 2*time.Minute || cfg.PingTimeout != 750*time.Millisecond { + t.Fatalf("unexpected config: %+v", cfg) + } +} + +func TestConfigFromEnvRejectsInvalidValues(t *testing.T) { + t.Setenv("GO_OMNI_DB_DSN", "demo") + t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "bad") + if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "MAX_OPEN_CONNS") { + t.Fatalf("expected invalid pool limit error, got %v", err) + } + t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "2") + t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "3") + if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "max idle") { + t.Fatalf("expected pool relationship error, got %v", err) + } + t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "1") + t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "0s") + if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "timeouts") { + t.Fatalf("expected timeout error, got %v", err) + } +} + +func TestOpenValidatesBeforeLoadingDriver(t *testing.T) { + _, err := Open(context.Background(), Config{Driver: "", DSN: "demo"}) + if err == nil || !strings.Contains(err.Error(), "driver") { + t.Fatalf("expected driver validation error, got %v", err) + } +} diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..f4b8bd6 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,74 @@ +package apierrors + +import ( + stdErrors "errors" + "net/http" +) + +// APIError is the public error shape used by handlers and middleware. +// Cause is retained for logging and unwrapping, but is never serialized. +type APIError struct { + Status int + Code string + Message string + Cause error +} + +func (e *APIError) Error() string { + if e == nil { + return "" + } + if e.Cause != nil { + return e.Code + ": " + e.Message + ": " + e.Cause.Error() + } + return e.Code + ": " + e.Message +} + +func (e *APIError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// WithCause attaches an internal cause while keeping the public response stable. +func (e *APIError) WithCause(cause error) *APIError { + e.Cause = cause + return e +} + +func New(status int, code, message string) *APIError { + return &APIError{Status: status, Code: code, Message: message} +} + +func BadRequest(code, message string) *APIError { + return New(http.StatusBadRequest, code, message) +} + +func Unauthorized(code, message string) *APIError { + return New(http.StatusUnauthorized, code, message) +} + +func Forbidden(code, message string) *APIError { + return New(http.StatusForbidden, code, message) +} + +func NotFound(code, message string) *APIError { + return New(http.StatusNotFound, code, message) +} + +func Internal(cause error) *APIError { + return New(http.StatusInternalServerError, "INTERNAL_ERROR", "internal server error").WithCause(cause) +} + +// From converts any error into the public API error shape. +func From(err error) *APIError { + if err == nil { + return nil + } + var apiErr *APIError + if stdErrors.As(err, &apiErr) && apiErr != nil { + return apiErr + } + return Internal(err) +} diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..5319166 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,20 @@ +package main + +import "git.yuntuo.hk/k2safe/go-omni" + +func main() { + app := omni.New(omni.WithLogger(true)) + + app.GET("/health", func(ctx *omni.Context) error { + return ctx.OK(map[string]string{"status": "ok"}) + }) + + api := app.Group("/api") + api.GET("/users/:id", func(ctx *omni.Context) error { + return ctx.OK(map[string]string{ + "id": ctx.Param("id"), + }) + }) + + _ = app.Run(":8080") +} diff --git a/examples/reference/README.md b/examples/reference/README.md new file mode 100644 index 0000000..0a65656 --- /dev/null +++ b/examples/reference/README.md @@ -0,0 +1,159 @@ +# 用户 CRUD 参考项目 + +这个示例展示如何用 GoOmni 写一个完整业务模块:参数绑定、业务规则、存储接口、分页、认证授权、统一响应、错误转换和自定义中间件。 + +## 运行 + +在 `go-omni` 根目录执行(Go 1.22 或以上): + +```sh +go run ./examples/reference +``` + +默认监听 `127.0.0.1:8080`,可通过环境变量调整: + +```sh +GO_OMNI_ADDR=127.0.0.1:8081 go run ./examples/reference +``` + +如果终端提示找不到 `go`,需要先把 Go 的 `bin` 目录加入 `PATH`。不要与 `examples/basic` 同时占用同一个端口。 + +默认使用内存存储,启动时为空,重启后数据清空。写接口需要 `Authorization: Bearer dev-token`,删除接口还需要 admin 角色;这是本地演示 Token,不可用于生产。设置 `GO_OMNI_DB_DSN` 后会改用 MySQL 存储,启动时会连接并 Ping 数据库;请先执行 [`schema.sql`](schema.sql)。 + +## 分层 + +```text +examples/reference/ +├── main.go # 组装存储、认证、业务服务、中间件和路由 +├── handler/ +│ ├── user.go # JSON / URI / Query 参数校验、HTTP 错误转换 +│ └── middleware.go # 自定义结构化请求日志 +├── service/ +│ └── user.go # 业务规则、输入规范化、UserRepository 接口 +├── repository/ +│ ├── memory.go # 并发安全的内存实现,原子保证邮箱唯一 +│ └── mysql.go # database/sql + MySQL 持久化实现 +├── model/ +│ ├── user.go # 用户、输入、分页结果 +│ └── errors.go # 业务错误 +└── main_test.go # 接口全流程与并发测试 +``` + +调用路径:HTTP → Handler → Service → Repository。 + +- Handler 依赖 GoOmni,示例代码不导入 Gin,也不调用 `Engine()` / `Raw()`。 +- Service、Repository、Model 不依赖 GoOmni 或 Gin,通过标准 `context.Context` 传递请求取消信号。 +- JSON 校验标签留在 Handler 的请求结构上;业务模型没有 HTTP 绑定标签。 +- Service 返回业务错误,由 Handler 映射为 HTTP 错误;未知错误交给框架转换成通用 500 响应。 +- 通过 `application.Group("/api/v1", handler.RequestLog(logger))` 挂载自定义中间件,再注册 `/users` 子路由。日志记录请求 ID、耗时和返回错误;panic 由框架恢复中间件处理。 + +## 接口约定 + +| 方法 | 路径 | 成功结果 | +| --- | --- | --- | +| GET | `/health` | 200,健康检查 | +| POST | `/api/v1/users` | 201,创建用户 | +| GET | `/api/v1/users` | 200,分页列表 | +| GET | `/api/v1/users/:id` | 200,用户详情 | +| PUT | `/api/v1/users/:id` | 200,完整替换姓名和邮箱 | +| DELETE | `/api/v1/users/:id` | 204,无响应体 | + +姓名和邮箱必填。姓名最长 100 个字符,去掉首尾空白后不能为空;邮箱最长 254 字节。本示例约定整个邮箱地址不区分大小写,按小写存储并唯一校验。 + +`PUT` 必须同时提交姓名和邮箱;ID 和时间戳由服务生成。删除后允许重新使用邮箱,新用户获得新的 ID。删除不存在的用户返回 404。 + +列表默认 `page=1&pageSize=20`,每页最多 100 条,按 ID 升序;超出最后一页返回空数组并保留总数。 + +## 试一遍完整流程 + +以下命令假定刚启动一个空实例,第一条创建的用户 ID 为 1: + +```sh +# 健康检查 +curl -i http://127.0.0.1:8080/health + +# 创建:201(开发 Token) +curl -i -X POST http://127.0.0.1:8080/api/v1/users \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer dev-token' \ + -H 'X-Request-ID: demo-create' \ + -d '{"name":"张三","email":"zhangsan@example.com"}' + +# 查询详情和分页列表:200 +curl -i http://127.0.0.1:8080/api/v1/users/1 +curl -i 'http://127.0.0.1:8080/api/v1/users?page=1&pageSize=10' + +# 相同邮箱(包括大小写变体):409 EMAIL_TAKEN +curl -i -X POST http://127.0.0.1:8080/api/v1/users \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer dev-token' \ + -d '{"name":"另一个人","email":"ZHANGSAN@example.com"}' + +# 完整修改:200 +curl -i -X PUT http://127.0.0.1:8080/api/v1/users/1 \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer dev-token' \ + -d '{"name":"张三(已更新)","email":"zhangsan.new@example.com"}' + +# 删除:204;随后查询:404 USER_NOT_FOUND +curl -i -X DELETE http://127.0.0.1:8080/api/v1/users/1 \ + -H 'Authorization: Bearer dev-token' +curl -i http://127.0.0.1:8080/api/v1/users/1 +``` + +分页结果的 `data` 结构: + +```json +{ + "items": [], + "total": 0, + "page": 1, + "pageSize": 20 +} +``` + +成功与失败都使用框架的 `code`、`message`、`requestId` 约定;删除成功只有 HTTP 204 和请求 ID 响应头。 + +| HTTP 状态 | code | 说明 | +| --- | --- | --- | +| 400 | `INVALID_BODY` | JSON 或必填字段、格式校验失败 | +| 400 | `INVALID_PATH` | ID 不是正整数 | +| 400 | `INVALID_QUERY` | 分页参数不合法 | +| 400 | `INVALID_USER` | 业务校验失败,例如姓名全为空白 | +| 404 | `USER_NOT_FOUND` | 用户不存在 | +| 409 | `EMAIL_TAKEN` | 邮箱已被其他用户使用 | +| 500 | `INTERNAL_ERROR` | 未预期的内部错误,响应不包含内部细节 | + +## 使用数据库 + +参考项目已经提供 MySQL 的 `service.UserRepository` 实现;Handler 和 Service 不需要修改。自定义数据库实现也应遵循相同契约: + +- 创建和修改时原子保证规范化后的邮箱唯一,例如使用数据库唯一索引;将唯一键冲突转换为 `model.ErrEmailTaken`。 +- 未找到用户返回 `model.ErrUserNotFound`;修改失败不保留部分更新。 +- 列表按 ID 排序、返回总数,空列表使用 `[]`。 +- 使用传入的 `context.Context` 执行查询,保留创建时间,修改时更新时间。 + +MySQL 运行配置通过环境变量传入: + +```sh +export GO_OMNI_DB_DRIVER=mysql +export GO_OMNI_DB_DSN='root:password@tcp(127.0.0.1:3306)/go_omni?parseTime=true&loc=UTC' +export GO_OMNI_DB_MAX_OPEN_CONNS=10 +export GO_OMNI_DB_MAX_IDLE_CONNS=5 +export GO_OMNI_DB_PING_TIMEOUT=5s +go run ./examples/reference +``` + +不设置 `GO_OMNI_DB_DSN` 就使用内存实现。可通过 `GO_OMNI_DEV_TOKEN` 替换本地演示 Token。数据库连接会在收到 `SIGINT` / `SIGTERM` 后随 HTTP 服务优雅退出而关闭;连接失败会阻止服务启动,不会静默回退到内存存储。 + +## 验证 + +在框架根目录执行: + +```sh +go test -race ./... +go vet ./... +go build ./... +``` + +测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。 diff --git a/examples/reference/handler/middleware.go b/examples/reference/handler/middleware.go new file mode 100644 index 0000000..eca2b94 --- /dev/null +++ b/examples/reference/handler/middleware.go @@ -0,0 +1,31 @@ +package handler + +import ( + "time" + + "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +// RequestLog demonstrates business middleware using only the GoOmni API. +func RequestLog(logger *logx.Logger) omni.Middleware { + return func(next omni.Handler) omni.Handler { + return func(ctx *omni.Context) error { + started := time.Now() + err := next(ctx) + attributes := []any{ + "requestId", ctx.RequestID(), + "method", ctx.Request().Method, + "path", ctx.Request().URL.Path, + "duration", time.Since(started), + } + if err != nil { + attributes = append(attributes, "error", err) + logger.ErrorContext(ctx.Request().Context(), "request failed", attributes...) + } else { + logger.InfoContext(ctx.Request().Context(), "request completed", attributes...) + } + return err + } + } +} diff --git a/examples/reference/handler/user.go b/examples/reference/handler/user.go new file mode 100644 index 0000000..05e5da8 --- /dev/null +++ b/examples/reference/handler/user.go @@ -0,0 +1,121 @@ +package handler + +import ( + "errors" + "net/http" + + "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/examples/reference/model" + "git.yuntuo.hk/k2safe/go-omni/examples/reference/service" +) + +type Users struct { + service *service.Users +} + +func NewUsers(service *service.Users) *Users { + return &Users{service: service} +} + +func (h *Users) RegisterRoutes(api *omni.RouterGroup, writeMiddleware, deleteMiddleware omni.Middleware) { + users := api.Group("/users") + users.POST("", h.Create, writeMiddleware) + users.GET("", h.List) + users.GET("/:id", h.Get) + users.PUT("/:id", h.Update, writeMiddleware) + users.DELETE("/:id", h.Delete, writeMiddleware, deleteMiddleware) +} + +// HTTP-specific validation tags stay here, outside the business model. +type userBody struct { + Name string `json:"name" binding:"required,max=100"` + Email string `json:"email" binding:"required,email,max=254"` +} + +type userPath struct { + ID int64 `uri:"id" binding:"gt=0"` +} + +type listQuery struct { + Page int `form:"page,default=1" binding:"gte=1"` + PageSize int `form:"pageSize,default=20" binding:"gte=1,lte=100"` +} + +func (h *Users) Create(ctx *omni.Context) error { + var body userBody + if err := ctx.BindJSON(&body); err != nil { + return err + } + user, err := h.service.Create(ctx.Request().Context(), model.UserInput(body)) + if err != nil { + return userError(err) + } + return ctx.Created(user) +} + +func (h *Users) Get(ctx *omni.Context) error { + var path userPath + if err := ctx.BindURI(&path); err != nil { + return err + } + user, err := h.service.Get(ctx.Request().Context(), path.ID) + if err != nil { + return userError(err) + } + return ctx.OK(user) +} + +func (h *Users) List(ctx *omni.Context) error { + var query listQuery + if err := ctx.BindQuery(&query); err != nil { + return err + } + page, err := h.service.List(ctx.Request().Context(), query.Page, query.PageSize) + if err != nil { + return userError(err) + } + return ctx.OK(page) +} + +func (h *Users) Update(ctx *omni.Context) error { + var path userPath + if err := ctx.BindURI(&path); err != nil { + return err + } + var body userBody + if err := ctx.BindJSON(&body); err != nil { + return err + } + user, err := h.service.Update(ctx.Request().Context(), path.ID, model.UserInput(body)) + if err != nil { + return userError(err) + } + return ctx.OK(user) +} + +func (h *Users) Delete(ctx *omni.Context) error { + var path userPath + if err := ctx.BindURI(&path); err != nil { + return err + } + if err := h.service.Delete(ctx.Request().Context(), path.ID); err != nil { + return userError(err) + } + return ctx.NoContent() +} + +func userError(err error) error { + switch { + case errors.Is(err, model.ErrUserNotFound): + return omni.NotFound("USER_NOT_FOUND", "user was not found").WithCause(err) + case errors.Is(err, model.ErrEmailTaken): + return omni.NewError(http.StatusConflict, "EMAIL_TAKEN", "email is already in use").WithCause(err) + case errors.Is(err, model.ErrInvalidUser): + return omni.BadRequest("INVALID_USER", "name or email is invalid").WithCause(err) + case errors.Is(err, model.ErrInvalidPage): + return omni.BadRequest("INVALID_QUERY", "pagination is invalid").WithCause(err) + default: + // GoOmni masks unexpected errors; internal details stay in server logs. + return err + } +} diff --git a/examples/reference/main.go b/examples/reference/main.go new file mode 100644 index 0000000..bb5e2cd --- /dev/null +++ b/examples/reference/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "os" + "os/signal" + "strings" + "syscall" + + "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/auth" + "git.yuntuo.hk/k2safe/go-omni/database" + "git.yuntuo.hk/k2safe/go-omni/examples/reference/handler" + "git.yuntuo.hk/k2safe/go-omni/examples/reference/repository" + "git.yuntuo.hk/k2safe/go-omni/examples/reference/service" + "git.yuntuo.hk/k2safe/go-omni/logx" + + _ "github.com/go-sql-driver/mysql" +) + +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) + return application +} + +func newProtectedApp(users service.UserRepository, logger *logx.Logger, authenticator auth.Authenticator, 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)) + write := auth.RequireAuth(authenticator) + delete := auth.RequireRole("admin") + handler.NewUsers(service.NewUsers(users)).RegisterRoutes(api, write, delete) + return application +} + +func main() { + logger, err := logx.New(logx.Config{ + Service: "reference-api", + Environment: "local", + Format: "json", + Writer: os.Stdout, + }) + if err != nil { + os.Exit(1) + } + defer logger.Close() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + users, closeStorage, storage, err := openUsers(ctx, logger) + if err != nil { + logger.Error("storage initialization failed", "error", err) + os.Exit(1) + } + defer func() { + if err := closeStorage(); err != nil { + logger.Error("storage close failed", "error", err) + } + }() + devToken := strings.TrimSpace(os.Getenv("GO_OMNI_DEV_TOKEN")) + if devToken == "" { + devToken = "dev-token" + } + authenticator := auth.StaticAuthenticator{ + devToken: {UserID: "dev-user", Roles: []string{"admin"}}, + } + application := newProtectedApp(users, logger, authenticator) + address := os.Getenv("GO_OMNI_ADDR") + if address == "" { + address = "127.0.0.1:8080" + } + logger.Info("starting reference API", "address", address, "storage", storage) + if err := application.RunContext(ctx, address); err != nil { + logger.Error("server stopped", "error", err) + os.Exit(1) + } +} + +func openUsers(ctx context.Context, logger *logx.Logger) (service.UserRepository, func() error, string, error) { + if strings.TrimSpace(os.Getenv("GO_OMNI_DB_DSN")) == "" { + return repository.NewMemoryUsers(), func() error { return nil }, "memory", nil + } + cfg, err := database.ConfigFromEnv("GO_OMNI_DB_") + if err != nil { + return nil, nil, "", err + } + db, err := database.Open(ctx, cfg) + if err != nil { + return nil, nil, "", err + } + return repository.NewMySQLUsers(db.DB), db.Close, "mysql", nil +} diff --git a/examples/reference/main_test.go b/examples/reference/main_test.go new file mode 100644 index 0000000..2f4ab08 --- /dev/null +++ b/examples/reference/main_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + "git.yuntuo.hk/k2safe/go-omni" + "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/logx" +) + +type envelope struct { + Code string `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + RequestID string `json:"requestId"` +} + +func testApp() *omni.App { + return newApp(repository.NewMemoryUsers(), testLogger(io.Discard), omni.WithMode("test")) +} + +func testLogger(writer io.Writer) *logx.Logger { + logger, err := logx.New(logx.Config{Format: "json", Writer: writer}) + if err != nil { + panic(err) + } + return logger +} + +func request(t *testing.T, app http.Handler, method, path, body string, status int, code string) envelope { + return requestWithAuthorization(t, app, method, path, body, "", status, code) +} + +func requestWithAuthorization(t *testing.T, app http.Handler, method, path, body, authorization string, status int, code string) envelope { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "reference-test") + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + w := httptest.NewRecorder() + app.ServeHTTP(w, req) + if w.Code != status { + t.Fatalf("%s %s: want status %d, got %d: %s", method, path, status, w.Code, w.Body.String()) + } + if w.Header().Get("X-Request-ID") != "reference-test" { + t.Fatalf("request ID header was not propagated: %v", w.Header()) + } + if status == http.StatusNoContent { + if w.Body.Len() != 0 { + t.Fatalf("204 must have no body: %s", w.Body.String()) + } + return envelope{} + } + if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") { + t.Fatalf("expected JSON content type: %v", w.Header()) + } + var result envelope + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON response: %v: %s", err, w.Body.String()) + } + if result.Code != code || result.RequestID != "reference-test" { + t.Fatalf("unexpected envelope: %+v", result) + } + return result +} + +func TestProtectedWriteRoutes(t *testing.T) { + app := newProtectedApp( + repository.NewMemoryUsers(), + testLogger(io.Discard), + auth.StaticAuthenticator{"user-token": {UserID: "user-1", Roles: []string{"user"}}}, + omni.WithMode("test"), + ) + request(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, 401, "UNAUTHENTICATED") + created := dataAs[model.User](t, requestWithAuthorization(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, "Bearer user-token", 201, "CREATED")) + path := "/api/v1/users/" + strconv.FormatInt(created.ID, 10) + requestWithAuthorization(t, app, "DELETE", path, "", "Bearer user-token", 403, "FORBIDDEN") + requestWithAuthorization(t, app, "PUT", path, `{"name":"Alice Updated","email":"alice.updated@example.com"}`, "Bearer user-token", 200, "OK") +} + +func dataAs[T any](t *testing.T, value envelope) T { + t.Helper() + var data T + if err := json.Unmarshal(value.Data, &data); err != nil { + t.Fatalf("decode data: %v", err) + } + return data +} + +func TestUserCRUD(t *testing.T) { + app := testApp() + health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK")) + if health["status"] != "ok" { + t.Fatalf("unexpected health: %v", health) + } + empty := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK")) + if empty.Items == nil || len(empty.Items) != 0 || empty.Total != 0 || empty.Page != 1 || empty.PageSize != 20 { + t.Fatalf("unexpected empty page: %+v", empty) + } + + alice := dataAs[model.User](t, request(t, app, "POST", "/api/v1/users", `{"name":" Alice ","email":"ALICE@example.com"}`, 201, "CREATED")) + if alice.ID <= 0 || alice.Name != "Alice" || alice.Email != "alice@example.com" || alice.CreatedAt.IsZero() || !alice.CreatedAt.Equal(alice.UpdatedAt) { + t.Fatalf("unexpected created user: %+v", alice) + } + path := "/api/v1/users/" + strconv.FormatInt(alice.ID, 10) + got := dataAs[model.User](t, request(t, app, "GET", path, "", 200, "OK")) + if got != alice { + t.Fatalf("get did not preserve user: %+v", got) + } + request(t, app, "POST", "/api/v1/users", `{"name":"Duplicate","email":"Alice@EXAMPLE.com"}`, 409, "EMAIL_TAKEN") + + bob := dataAs[model.User](t, request(t, app, "POST", "/api/v1/users", `{"name":"Bob","email":"bob@example.com"}`, 201, "CREATED")) + page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users?page=2&pageSize=1", "", 200, "OK")) + if page.Total != 2 || page.Page != 2 || page.PageSize != 1 || len(page.Items) != 1 || page.Items[0] != bob { + t.Fatalf("unexpected page: %+v", page) + } + // An extreme page must return an empty list without an integer overflow. + hugePage := strconv.Itoa(int(^uint(0) >> 1)) + outOfRange := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users?page="+hugePage+"&pageSize=100", "", 200, "OK")) + if outOfRange.Items == nil || len(outOfRange.Items) != 0 || outOfRange.Total != 2 { + t.Fatalf("unexpected out-of-range page: %+v", outOfRange) + } + + // A conflicting update must not partially modify either user or email index. + request(t, app, "PUT", path, `{"name":"Changed","email":"BOB@example.com"}`, 409, "EMAIL_TAKEN") + unchanged := dataAs[model.User](t, request(t, app, "GET", path, "", 200, "OK")) + if unchanged != alice { + t.Fatalf("failed update changed data: %+v", unchanged) + } + updated := dataAs[model.User](t, request(t, app, "PUT", path, `{"name":"Alice Updated","email":"new@example.com"}`, 200, "OK")) + if updated.ID != alice.ID || updated.Name != "Alice Updated" || updated.Email != "new@example.com" || !updated.CreatedAt.Equal(alice.CreatedAt) || updated.UpdatedAt.Before(alice.UpdatedAt) { + t.Fatalf("unexpected update: %+v", updated) + } + // Retaining one's own email is allowed; the old email can be reused. + request(t, app, "PUT", path, `{"name":"Alice Updated","email":"new@example.com"}`, 200, "OK") + request(t, app, "POST", "/api/v1/users", `{"name":"Reused","email":"alice@example.com"}`, 201, "CREATED") + request(t, app, "DELETE", path, "", 204, "") + request(t, app, "GET", path, "", 404, "USER_NOT_FOUND") + request(t, app, "DELETE", path, "", 404, "USER_NOT_FOUND") + request(t, app, "POST", "/api/v1/users", `{"name":"Reused Again","email":"new@example.com"}`, 201, "CREATED") +} + +func TestUserValidation(t *testing.T) { + app := testApp() + for _, test := range []struct { + name, method, path, body, code string + }{ + {"malformed JSON", "POST", "/api/v1/users", `{"name":`, "INVALID_BODY"}, + {"missing email", "POST", "/api/v1/users", `{"name":"Alice"}`, "INVALID_BODY"}, + {"invalid email", "POST", "/api/v1/users", `{"name":"Alice","email":"invalid"}`, "INVALID_BODY"}, + {"blank name", "POST", "/api/v1/users", `{"name":" ","email":"alice@example.com"}`, "INVALID_USER"}, + {"negative ID", "GET", "/api/v1/users/-1", "", "INVALID_PATH"}, + {"zero ID", "DELETE", "/api/v1/users/0", "", "INVALID_PATH"}, + {"text ID", "PUT", "/api/v1/users/abc", `{}`, "INVALID_PATH"}, + {"overflow ID", "GET", "/api/v1/users/99999999999999999999999", "", "INVALID_PATH"}, + {"zero page", "GET", "/api/v1/users?page=0", "", "INVALID_QUERY"}, + {"negative page", "GET", "/api/v1/users?page=-1", "", "INVALID_QUERY"}, + {"text page", "GET", "/api/v1/users?page=abc", "", "INVALID_QUERY"}, + {"zero page size", "GET", "/api/v1/users?pageSize=0", "", "INVALID_QUERY"}, + {"large page size", "GET", "/api/v1/users?pageSize=101", "", "INVALID_QUERY"}, + {"incomplete replacement", "PUT", "/api/v1/users/1", `{"name":"Alice"}`, "INVALID_BODY"}, + } { + t.Run(test.name, func(t *testing.T) { + request(t, app, test.method, test.path, test.body, 400, test.code) + }) + } + request(t, app, "GET", "/api/v1/users/123", "", 404, "USER_NOT_FOUND") + request(t, app, "PUT", "/api/v1/users/123", `{"name":"Alice","email":"alice@example.com"}`, 404, "USER_NOT_FOUND") + page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK")) + if page.Total != 0 { + t.Fatalf("invalid requests mutated storage: %+v", page) + } +} + +type failingUsers struct { + *repository.MemoryUsers + err error +} + +func (r failingUsers) Get(context.Context, int64) (model.User, error) { + return model.User{}, r.err +} + +func TestErrorTranslationAndRequestLogging(t *testing.T) { + for _, test := range []struct { + name, code string + err error + status int + }{ + {"internal failure", "INTERNAL_ERROR", errors.New("private database diagnostic"), 500}, + {"wrapped domain error", "USER_NOT_FOUND", fmt.Errorf("lookup failed: %w", model.ErrUserNotFound), 404}, + } { + t.Run(test.name, func(t *testing.T) { + var logs bytes.Buffer + app := newApp(failingUsers{repository.NewMemoryUsers(), test.err}, testLogger(&logs), omni.WithMode("test")) + result := request(t, app, "GET", "/api/v1/users/1", "", test.status, test.code) + if strings.Contains(result.Message, test.err.Error()) || len(result.Data) != 0 { + t.Fatalf("internal error details leaked: %+v", result) + } + var logEntry map[string]any + if err := json.Unmarshal(logs.Bytes(), &logEntry); err != nil { + t.Fatalf("missing or invalid request log: %v: %s", err, logs.String()) + } + if logEntry["requestId"] != "reference-test" || logEntry["path"] != "/api/v1/users/1" || !strings.Contains(fmt.Sprint(logEntry["error"]), test.err.Error()) { + t.Fatalf("missing request/error context in log: %v", logEntry) + } + }) + } +} + +func TestConcurrentEmailUniqueness(t *testing.T) { + app := testApp() + server := httptest.NewServer(app) + defer server.Close() + const count = 24 + results := make(chan int, count) + errors := make(chan error, count) + var workers sync.WaitGroup + for i := 0; i < count; i++ { + workers.Add(1) + go func(i int) { + defer workers.Done() + email := "same@example.com" + if i%2 == 0 { + email = "SAME@example.com" + } + body := fmt.Sprintf(`{"name":"User %d","email":%q}`, i, email) + response, err := server.Client().Post(server.URL+"/api/v1/users", "application/json", strings.NewReader(body)) + if err != nil { + errors <- err + return + } + io.Copy(io.Discard, response.Body) + response.Body.Close() + results <- response.StatusCode + }(i) + } + workers.Wait() + close(results) + close(errors) + for err := range errors { + t.Errorf("request failed: %v", err) + } + created, conflicts := 0, 0 + for status := range results { + switch status { + case 201: + created++ + case 409: + conflicts++ + default: + t.Errorf("unexpected status: %d", status) + } + } + if created != 1 || conflicts != count-1 { + t.Fatalf("want one create and %d conflicts, got %d creates and %d conflicts", count-1, created, conflicts) + } + page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK")) + if page.Total != 1 { + t.Fatalf("duplicate users persisted: %+v", page) + } +} diff --git a/examples/reference/model/errors.go b/examples/reference/model/errors.go new file mode 100644 index 0000000..3f6e7c8 --- /dev/null +++ b/examples/reference/model/errors.go @@ -0,0 +1,11 @@ +package model + +import "errors" + +// Domain errors are translated into HTTP responses only by the handler layer. +var ( + ErrUserNotFound = errors.New("user not found") + ErrEmailTaken = errors.New("email already in use") + ErrInvalidUser = errors.New("invalid user") + ErrInvalidPage = errors.New("invalid pagination") +) diff --git a/examples/reference/model/user.go b/examples/reference/model/user.go new file mode 100644 index 0000000..376f3eb --- /dev/null +++ b/examples/reference/model/user.go @@ -0,0 +1,25 @@ +package model + +import "time" + +// User is the business entity; it has no dependency on HTTP or a database driver. +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// UserInput is shared by creation and full replacement (PUT). +type UserInput struct { + Name string + Email string +} + +type UserPage struct { + Items []User `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} diff --git a/examples/reference/repository/memory.go b/examples/reference/repository/memory.go new file mode 100644 index 0000000..d044ee9 --- /dev/null +++ b/examples/reference/repository/memory.go @@ -0,0 +1,126 @@ +package repository + +import ( + "context" + "sort" + "sync" + "time" + + "git.yuntuo.hk/k2safe/go-omni/examples/reference/model" +) + +// MemoryUsers stores data only for this process. Replace it with a database +// implementation of service.UserRepository when persistence is required. +type MemoryUsers struct { + mu sync.RWMutex + users map[int64]model.User + emails map[string]int64 + nextID int64 +} + +func NewMemoryUsers() *MemoryUsers { + return &MemoryUsers{ + users: make(map[int64]model.User), + emails: make(map[string]int64), + nextID: 1, + } +} + +func (r *MemoryUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + if err := ctx.Err(); err != nil { + return model.User{}, err + } + if _, exists := r.emails[input.Email]; exists { + return model.User{}, model.ErrEmailTaken + } + now := time.Now().UTC() + user := model.User{ + ID: r.nextID, Name: input.Name, Email: input.Email, + CreatedAt: now, UpdatedAt: now, + } + r.nextID++ + r.users[user.ID] = user + r.emails[user.Email] = user.ID + return user, nil +} + +func (r *MemoryUsers) Get(ctx context.Context, id int64) (model.User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := ctx.Err(); err != nil { + return model.User{}, err + } + user, exists := r.users[id] + if !exists { + return model.User{}, model.ErrUserNotFound + } + return user, nil +} + +func (r *MemoryUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) { + if page < 1 || pageSize < 1 || pageSize > 100 { + return model.UserPage{}, model.ErrInvalidPage + } + r.mu.RLock() + if err := ctx.Err(); err != nil { + r.mu.RUnlock() + return model.UserPage{}, err + } + users := make([]model.User, 0, len(r.users)) + for _, user := range r.users { + users = append(users, user) + } + r.mu.RUnlock() + + // A stable order is essential: Go map iteration order changes between calls. + sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID }) + result := model.UserPage{ + Items: []model.User{}, Total: len(users), Page: page, PageSize: pageSize, + } + // Check the page before multiplying so even a very large page cannot overflow. + if len(users) == 0 || page-1 > (len(users)-1)/pageSize { + return result, nil + } + start := (page - 1) * pageSize + count := min(pageSize, len(users)-start) + result.Items = users[start : start+count] + return result, nil +} + +func (r *MemoryUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + if err := ctx.Err(); err != nil { + return model.User{}, err + } + user, exists := r.users[id] + if !exists { + return model.User{}, model.ErrUserNotFound + } + if owner, exists := r.emails[input.Email]; exists && owner != id { + return model.User{}, model.ErrEmailTaken + } + delete(r.emails, user.Email) + user.Name, user.Email = input.Name, input.Email + user.UpdatedAt = time.Now().UTC() + r.users[id] = user + r.emails[user.Email] = id + return user, nil +} + +func (r *MemoryUsers) Delete(ctx context.Context, id int64) error { + r.mu.Lock() + defer r.mu.Unlock() + if err := ctx.Err(); err != nil { + return err + } + user, exists := r.users[id] + if !exists { + return model.ErrUserNotFound + } + delete(r.users, id) + delete(r.emails, user.Email) + return nil +} diff --git a/examples/reference/repository/memory_test.go b/examples/reference/repository/memory_test.go new file mode 100644 index 0000000..62a317b --- /dev/null +++ b/examples/reference/repository/memory_test.go @@ -0,0 +1,40 @@ +package repository_test + +import ( + "context" + "errors" + "testing" + + "git.yuntuo.hk/k2safe/go-omni/examples/reference/model" + "git.yuntuo.hk/k2safe/go-omni/examples/reference/repository" +) + +func TestCanceledRequestsDoNotMutateStorage(t *testing.T) { + store := repository.NewMemoryUsers() + live := context.Background() + user, err := store.Create(live, model.UserInput{Name: "Alice", Email: "alice@example.com"}) + if err != nil { + t.Fatal(err) + } + canceled, cancel := context.WithCancel(live) + cancel() + if _, err := store.Create(canceled, model.UserInput{Name: "Bob", Email: "bob@example.com"}); !errors.Is(err, context.Canceled) { + t.Fatalf("create: %v", err) + } + if _, err := store.Update(canceled, user.ID, model.UserInput{Name: "Changed", Email: "changed@example.com"}); !errors.Is(err, context.Canceled) { + t.Fatalf("update: %v", err) + } + if err := store.Delete(canceled, user.ID); !errors.Is(err, context.Canceled) { + t.Fatalf("delete: %v", err) + } + if _, err := store.Get(canceled, user.ID); !errors.Is(err, context.Canceled) { + t.Fatalf("get: %v", err) + } + if _, err := store.List(canceled, 1, 20); !errors.Is(err, context.Canceled) { + t.Fatalf("list: %v", err) + } + page, err := store.List(live, 1, 20) + if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0] != user { + t.Fatalf("canceled requests changed stored data: %+v, %v", page, err) + } +} diff --git a/examples/reference/repository/mysql.go b/examples/reference/repository/mysql.go new file mode 100644 index 0000000..b880bb1 --- /dev/null +++ b/examples/reference/repository/mysql.go @@ -0,0 +1,122 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + "git.yuntuo.hk/k2safe/go-omni/examples/reference/model" + "github.com/go-sql-driver/mysql" +) + +// MySQLUsers is the persistent implementation of service.UserRepository. +// The schema is documented in schema.sql; migrations stay outside the framework. +type MySQLUsers struct { + db *sql.DB +} + +func NewMySQLUsers(db *sql.DB) *MySQLUsers { + return &MySQLUsers{db: db} +} + +func (r *MySQLUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) { + result, err := r.db.ExecContext(ctx, ` + INSERT INTO users (name, email, created_at, updated_at) + VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, input.Name, input.Email) + if err != nil { + return model.User{}, mapMySQLError(err) + } + id, err := result.LastInsertId() + if err != nil { + return model.User{}, err + } + return r.Get(ctx, id) +} + +func (r *MySQLUsers) Get(ctx context.Context, id int64) (model.User, error) { + var user model.User + err := r.db.QueryRowContext(ctx, ` + SELECT id, name, email, created_at, updated_at + FROM users WHERE id = ?`, id).Scan( + &user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return model.User{}, model.ErrUserNotFound + } + if err != nil { + return model.User{}, err + } + return user, nil +} + +func (r *MySQLUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) { + var total int + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil { + return model.UserPage{}, err + } + result := model.UserPage{Items: []model.User{}, Total: total, Page: page, PageSize: pageSize} + if total == 0 || page-1 > (total-1)/pageSize { + return result, nil + } + offset := (page - 1) * pageSize + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, email, created_at, updated_at + FROM users ORDER BY id LIMIT ? OFFSET ?`, pageSize, offset) + if err != nil { + return model.UserPage{}, err + } + defer rows.Close() + for rows.Next() { + var user model.User + if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil { + return model.UserPage{}, err + } + result.Items = append(result.Items, user) + } + if err := rows.Err(); err != nil { + return model.UserPage{}, err + } + return result, nil +} + +func (r *MySQLUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) { + result, err := r.db.ExecContext(ctx, ` + UPDATE users SET name = ?, email = ?, updated_at = UTC_TIMESTAMP(6) + WHERE id = ?`, input.Name, input.Email, id) + if err != nil { + return model.User{}, mapMySQLError(err) + } + changed, err := result.RowsAffected() + if err != nil { + return model.User{}, err + } + if changed == 0 { + // MySQL may report zero affected rows when the replacement is identical; + // distinguish that from an ID that does not exist. + return r.Get(ctx, id) + } + return r.Get(ctx, id) +} + +func (r *MySQLUsers) Delete(ctx context.Context, id int64) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) + if err != nil { + return err + } + deleted, err := result.RowsAffected() + if err != nil { + return err + } + if deleted == 0 { + return model.ErrUserNotFound + } + return nil +} + +func mapMySQLError(err error) error { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return model.ErrEmailTaken + } + return err +} diff --git a/examples/reference/schema.sql b/examples/reference/schema.sql new file mode 100644 index 0000000..b7ff473 --- /dev/null +++ b/examples/reference/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE users ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + email VARCHAR(254) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_users_email (email) +); diff --git a/examples/reference/service/user.go b/examples/reference/service/user.go new file mode 100644 index 0000000..49faafa --- /dev/null +++ b/examples/reference/service/user.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "net/mail" + "strings" + "unicode/utf8" + + "git.yuntuo.hk/k2safe/go-omni/examples/reference/model" +) + +// UserRepository describes the storage operations the business needs. +// Implementations must atomically enforce unique, normalized email addresses. +type UserRepository interface { + Create(context.Context, model.UserInput) (model.User, error) + Get(context.Context, int64) (model.User, error) + List(context.Context, int, int) (model.UserPage, error) + Update(context.Context, int64, model.UserInput) (model.User, error) + Delete(context.Context, int64) error +} + +type Users struct { + repository UserRepository +} + +func NewUsers(repository UserRepository) *Users { + return &Users{repository: repository} +} + +func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User, error) { + input, err := normalize(input) + if err != nil { + return model.User{}, err + } + return s.repository.Create(ctx, input) +} + +func (s *Users) Get(ctx context.Context, id int64) (model.User, error) { + return s.repository.Get(ctx, id) +} + +func (s *Users) List(ctx context.Context, page, pageSize int) (model.UserPage, error) { + if page < 1 || pageSize < 1 || pageSize > 100 { + return model.UserPage{}, model.ErrInvalidPage + } + return s.repository.List(ctx, page, pageSize) +} + +func (s *Users) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) { + input, err := normalize(input) + if err != nil { + return model.User{}, err + } + return s.repository.Update(ctx, id, input) +} + +func (s *Users) Delete(ctx context.Context, id int64) error { + return s.repository.Delete(ctx, id) +} + +func normalize(input model.UserInput) (model.UserInput, error) { + input.Name = strings.TrimSpace(input.Name) + // This example treats the entire email address as case-insensitive. + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + address, err := mail.ParseAddress(input.Email) + if input.Name == "" || utf8.RuneCountInString(input.Name) > 100 || + len(input.Email) > 254 || err != nil || address.Address != input.Email { + return model.UserInput{}, model.ErrInvalidUser + } + return input, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..85baed5 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module git.yuntuo.hk/k2safe/go-omni + +go 1.22 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/go-playground/validator/v10 v10.20.0 + github.com/go-sql-driver/mysql v1.9.3 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f2bfde9 --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/health/README.md b/health/README.md new file mode 100644 index 0000000..a2f04fc --- /dev/null +++ b/health/README.md @@ -0,0 +1,17 @@ +# health + +`health` 提供与网络层无关的存活和就绪 Handler。业务项目只需要注册依赖探针,路由仍通过 `omni.App` 或项目自己的 transport 注册: + +```go +checker, err := health.New(health.Probe{ + Name: "database", + Check: db.PingContext, +}) +if err != nil { + return err +} +app.GET("/health/live", checker.LivenessHandler()) +app.GET("/health/ready", checker.ReadinessHandler()) +``` + +就绪探针会保留每项检查结果,并在至少一项失败时返回 `503` 和 `NOT_READY`。 diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000..5cb7916 --- /dev/null +++ b/health/health.go @@ -0,0 +1,153 @@ +// Package health provides transport-neutral liveness and readiness handlers. +package health + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +const ( + StatusReady = "ready" + StatusNotReady = "not_ready" +) + +// Probe is one dependency check used by readiness. Timeout is optional; a +// zero value lets the request context control the probe duration. +type Probe struct { + Name string + Check func(context.Context) error + Timeout time.Duration +} + +// CheckResult is the public result of one readiness probe. +type CheckResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + DurationMs float64 `json:"durationMs"` +} + +// Result is returned by Checker.Check and the readiness endpoint. +type Result struct { + Status string `json:"status"` + Checks []CheckResult `json:"checks,omitempty"` +} + +func (r Result) Ready() bool { return r.Status == StatusReady } + +// Checker owns the probes registered by a service. It can be shared by all +// requests after startup; registration is safe before or during serving. +type Checker struct { + mu sync.RWMutex + probes []Probe +} + +// New creates a checker and validates its initial probes. +func New(probes ...Probe) (*Checker, error) { + checker := &Checker{} + for _, probe := range probes { + if err := checker.Add(probe); err != nil { + return nil, err + } + } + return checker, nil +} + +// Add registers a named readiness probe. Names must be unique and checks must +// be non-nil so configuration errors fail during startup. +func (c *Checker) Add(probe Probe) error { + if c == nil { + return fmt.Errorf("health: nil checker") + } + probe.Name = strings.TrimSpace(probe.Name) + if probe.Name == "" { + return fmt.Errorf("health: probe name is required") + } + if probe.Check == nil { + return fmt.Errorf("health: probe %q has no check", probe.Name) + } + if probe.Timeout < 0 { + return fmt.Errorf("health: probe %q timeout must not be negative", probe.Name) + } + + c.mu.Lock() + defer c.mu.Unlock() + for _, current := range c.probes { + if current.Name == probe.Name { + return fmt.Errorf("health: duplicate probe %q", probe.Name) + } + } + c.probes = append(c.probes, probe) + return nil +} + +// Check runs every registered probe in registration order. A failed probe is +// represented in the result; the other probes still run so operators see all +// unavailable dependencies in one response. +func (c *Checker) Check(ctx context.Context) Result { + if ctx == nil { + ctx = context.Background() + } + c.mu.RLock() + probes := append([]Probe(nil), c.probes...) + c.mu.RUnlock() + + result := Result{Status: StatusReady, Checks: make([]CheckResult, 0, len(probes))} + for _, probe := range probes { + started := time.Now() + probeContext := ctx + cancel := func() {} + if probe.Timeout > 0 { + probeContext, cancel = context.WithTimeout(ctx, probe.Timeout) + } + err := probe.Check(probeContext) + cancel() + checkResult := CheckResult{ + Name: probe.Name, + Status: StatusReady, + DurationMs: time.Since(started).Seconds() * 1000, + } + if err != nil { + result.Status = StatusNotReady + checkResult.Status = StatusNotReady + checkResult.Error = err.Error() + } + result.Checks = append(result.Checks, checkResult) + } + return result +} + +// LivenessHandler reports that the process is running. It does not execute +// dependency probes and is safe to use for orchestrator liveness checks. +func (c *Checker) LivenessHandler() httpx.Handler { + return func(ctx *httpx.Context) error { + return ctx.OK(map[string]string{"status": "ok"}) + } +} + +// ReadinessHandler reports whether all registered dependencies are ready. +// Failure uses HTTP 503 while preserving GoOmni's response envelope. +func (c *Checker) ReadinessHandler() httpx.Handler { + return func(ctx *httpx.Context) error { + result := c.Check(ctx.Context()) + status := http.StatusOK + code, message := "READY", "ready" + if !result.Ready() { + status = http.StatusServiceUnavailable + code, message = "NOT_READY", "service is not ready" + } + return ctx.JSON(status, response.Response{ + Code: code, + Message: message, + Data: result, + RequestID: ctx.RequestID(), + }) + } +} diff --git a/health/health_test.go b/health/health_test.go new file mode 100644 index 0000000..a9e815b --- /dev/null +++ b/health/health_test.go @@ -0,0 +1,73 @@ +package health + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" +) + +func TestCheckerReadinessReportsAllProbes(t *testing.T) { + checker, err := New( + Probe{Name: "database", Check: func(context.Context) error { return nil }}, + Probe{Name: "cache", Check: func(context.Context) error { return errors.New("connection refused") }}, + ) + if err != nil { + t.Fatal(err) + } + app := omni.NewWithRouter(nethttp.New()) + app.GET("/live", checker.LivenessHandler()) + app.GET("/ready", checker.ReadinessHandler()) + + live := httptest.NewRecorder() + app.ServeHTTP(live, httptest.NewRequest(http.MethodGet, "/live", nil)) + if live.Code != http.StatusOK { + t.Fatalf("expected liveness 200, got %d", live.Code) + } + + ready := httptest.NewRecorder() + app.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/ready", nil)) + if ready.Code != http.StatusServiceUnavailable { + t.Fatalf("expected readiness 503, got %d", ready.Code) + } + var body struct { + Code string `json:"code"` + Data Result `json:"data"` + } + if err := json.Unmarshal(ready.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "connection refused" { + t.Fatalf("unexpected readiness response: %+v", body) + } +} + +func TestCheckerTimeoutAndRegistrationValidation(t *testing.T) { + checker, err := New(Probe{ + Name: "slow", + Timeout: 5 * time.Millisecond, + Check: func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }, + }) + if err != nil { + t.Fatal(err) + } + result := checker.Check(context.Background()) + if result.Ready() || result.Checks[0].Status != StatusNotReady { + t.Fatalf("expected timeout to fail readiness: %+v", result) + } + + for _, probe := range []Probe{{Check: func(context.Context) error { return nil }}, {Name: "slow", Check: func(context.Context) error { return nil }}, {Name: "negative", Timeout: -time.Second, Check: func(context.Context) error { return nil }}} { + if err := checker.Add(probe); err == nil { + t.Fatalf("expected registration error for %+v", probe) + } + } +} diff --git a/httpx/context.go b/httpx/context.go new file mode 100644 index 0000000..b456e45 --- /dev/null +++ b/httpx/context.go @@ -0,0 +1,227 @@ +package httpx + +import ( + "context" + "encoding/json" + "net/http" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/response" + "git.yuntuo.hk/k2safe/go-omni/validate" +) + +// Handler is the function signature used by GoOmni routes. +type Handler func(*Context) error + +// Middleware wraps a Handler and may enforce cross-cutting behavior. +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. +type Router interface { + http.Handler + Handle(method, path string, handler http.Handler) + Any(path string, handler http.Handler) + Group(path string) RouterGroup +} + +// RouterGroup is the route-registration portion of Router. +type RouterGroup interface { + Handle(method, path string, handler http.Handler) + Any(path string, handler http.Handler) + Group(path string) RouterGroup +} + +type requestContextKey uint8 + +const ( + routeParamsKey requestContextKey = iota + 1 + routePatternKey +) + +// WithRouteParams lets a router adapter expose path parameters to the +// transport-neutral Context. +func WithRouteParams(ctx context.Context, params map[string]string) context.Context { + copyOfParams := make(map[string]string, len(params)) + for key, value := range params { + copyOfParams[key] = value + } + return context.WithValue(ctx, routeParamsKey, copyOfParams) +} + +// RouteParams returns path parameters installed by a router adapter. +func RouteParams(ctx context.Context) map[string]string { + if ctx == nil { + return nil + } + params, _ := ctx.Value(routeParamsKey).(map[string]string) + return params +} + +// WithRoutePattern records the router's matched route pattern for access logs. +func WithRoutePattern(ctx context.Context, pattern string) context.Context { + return context.WithValue(ctx, routePatternKey, pattern) +} + +// RoutePattern returns the matched route pattern, when the adapter provides it. +func RoutePattern(ctx context.Context) string { + if ctx == nil { + return "" + } + pattern, _ := ctx.Value(routePatternKey).(string) + return pattern +} + +// Context provides the business-facing request and response API. It contains +// only standard-library HTTP types, so it remains usable when Gin is replaced. +type Context struct { + request *http.Request + writer http.ResponseWriter + params map[string]string + values map[string]any + responded bool + status int +} + +// NewContext creates a transport-neutral GoOmni context. +func NewContext(request *http.Request, writer http.ResponseWriter, params map[string]string) *Context { + if request == nil { + request = &http.Request{} + } + return &Context{ + request: request, + writer: writer, + params: params, + values: make(map[string]any), + } +} + +func (c *Context) Request() *http.Request { return c.request } + +// Context returns the standard request context. It carries GoOmni +// correlation values such as the request ID and can be passed to services. +func (c *Context) Context() context.Context { + if c.request == nil { + return context.Background() + } + return c.request.Context() +} + +// Raw returns the response writer supplied by the active network adapter. +// Prefer the transport-neutral methods on Context for portable handlers. +func (c *Context) Raw() http.ResponseWriter { return c.writer } + +func (c *Context) Param(name string) string { return c.params[name] } + +func (c *Context) Query(name string) string { + if c.request == nil { + return "" + } + return c.request.URL.Query().Get(name) +} + +func (c *Context) Header(name string) string { + if c.request == nil { + return "" + } + return c.request.Header.Get(name) +} + +func (c *Context) RequestID() string { return logx.RequestID(c.Context()) } + +func (c *Context) Set(key string, value any) { c.values[key] = value } + +func (c *Context) Get(key string) (any, bool) { + value, ok := c.values[key] + return value, ok +} + +// WithContext replaces the standard request context while preserving the +// request and its headers. +func (c *Context) WithContext(ctx context.Context) { + if c.request != nil { + c.request = c.request.WithContext(ctx) + } +} + +// SetResponseHeader sets a header on the underlying response. +func (c *Context) SetResponseHeader(name, value string) { + if c.writer != nil { + c.writer.Header().Set(name, value) + } +} + +func (c *Context) BindJSON(value any) error { return validate.JSON(c.request, value) } + +func (c *Context) BindQuery(value any) error { return validate.Query(c.request, value) } + +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.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 { + return apierrors.Internal(err) + } + c.responded = true + c.status = status + return nil +} + +func (c *Context) OK(data any) error { return c.respond(http.StatusOK, "OK", "success", data) } + +func (c *Context) Created(data any) error { + return c.respond(http.StatusCreated, "CREATED", "created", data) +} + +func (c *Context) Accepted(data any) error { + return c.respond(http.StatusAccepted, "ACCEPTED", "accepted", data) +} + +func (c *Context) NoContent() error { + c.writer.WriteHeader(http.StatusNoContent) + c.responded = true + c.status = http.StatusNoContent + return nil +} + +func (c *Context) respond(status int, code, message string, data any) error { + return c.JSON(status, response.Response{ + Code: code, + Message: message, + Data: data, + RequestID: c.RequestID(), + }) +} + +// Status returns the response status observed by Context. +func (c *Context) Status() int { + if c.status == 0 { + return http.StatusOK + } + return c.status +} + +// Responded reports whether this handler has already written a response. +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. +func (c *Context) WriteError(err error) { + if c.responded { + return + } + apiErr := apierrors.From(err) + _ = c.JSON(apiErr.Status, response.Response{ + Code: apiErr.Code, + Message: apiErr.Message, + RequestID: c.RequestID(), + }) +} diff --git a/logx/README.md b/logx/README.md new file mode 100644 index 0000000..3df027b --- /dev/null +++ b/logx/README.md @@ -0,0 +1,31 @@ +# logx + +`logx` 是 GoOmni 的业务层结构化日志入口。业务代码不需要依赖 Gin: + +```go +import "git.yuntuo.hk/k2safe/go-omni/logx" + +func (s *UserService) Create(ctx context.Context, input CreateInput) error { + logx.InfoContext(ctx, "user.create.start", "email", input.Email) + // ... + return nil +} +``` + +应用启动时可以替换默认输出: + +```go +closeLog, err := logx.Setup(logx.Config{ + Service: "billing-api", + Environment: "production", + Level: "info", + Format: "json", + File: "./var/log/billing-api.log", +}) +if err != nil { + return err +} +defer closeLog() +``` + +HTTP 请求的 `X-Request-ID` 会被放进标准 `context.Context`,之后的 `InfoContext`、`ErrorContext` 会自动带上 `requestId`。后续可以在不改变业务调用方式的前提下增加文件轮转、采样和 OpenTelemetry 导出。 diff --git a/logx/logx.go b/logx/logx.go new file mode 100644 index 0000000..b7f2603 --- /dev/null +++ b/logx/logx.go @@ -0,0 +1,327 @@ +// Package logx provides the application logger used by GoOmni and business +// packages. Its public API is deliberately independent of Gin so the same +// logger can be used from HTTP handlers, jobs and command line programs. +package logx + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + "sync" + "sync/atomic" +) + +// Config controls a logger created by New or Setup. +// +// Writer is useful for tests and embedding. When it is nil, Output selects a +// standard stream. File takes precedence over Output and appends to the named +// file. +type Config struct { + Level string + Format string + Output string + File string + Service string + Environment string + AddSource bool + Writer io.Writer +} + +// Default returns the default production-friendly logger configuration. +func Default() Config { + return Config{ + Level: "info", + Format: "json", + Output: "stderr", + } +} + +// Logger is a structured logger. Child loggers returned by With and WithGroup +// share the root logger's lifecycle and must not be closed independently. +type Logger struct { + logger *slog.Logger + closer *closeState +} + +type closeState struct { + once sync.Once + fn func() error + err error +} + +var ( + defaultLogger atomic.Pointer[Logger] + setupMu sync.Mutex +) + +func init() { + // Keep package-level logging useful without requiring every service to + // repeat setup. Applications can replace this logger with Setup. + defaultLogger.Store(&Logger{logger: slog.New(slog.NewJSONHandler(os.Stderr, nil))}) +} + +// New creates an independent logger. The caller owns its lifecycle and should +// call Close when the process is shutting down. +func New(cfg Config) (*Logger, error) { + cfg = normalizeConfig(cfg) + level, err := parseLevel(cfg.Level) + if err != nil { + return nil, err + } + + writer, closeWriter, err := openWriter(cfg) + if err != nil { + return nil, err + } + + opts := &slog.HandlerOptions{Level: level, AddSource: cfg.AddSource} + var handler slog.Handler + if cfg.Format == "text" { + handler = slog.NewTextHandler(writer, opts) + } else { + handler = slog.NewJSONHandler(writer, opts) + } + logger := slog.New(handler) + if cfg.Service != "" { + logger = logger.With("service", cfg.Service) + } + if cfg.Environment != "" { + logger = logger.With("environment", cfg.Environment) + } + + var closer *closeState + if closeWriter != nil { + closer = &closeState{fn: closeWriter} + } + return &Logger{logger: logger, closer: closer}, nil +} + +// Setup replaces the process-wide logger used by the package-level helpers. +// The returned function closes the newly configured logger. Calling Close is +// usually more convenient for application shutdown. +func Setup(cfg Config) (func() error, error) { + logger, err := New(cfg) + if err != nil { + return nil, err + } + setupMu.Lock() + previous := defaultLogger.Swap(logger) + setupMu.Unlock() + if previous != nil { + _ = previous.Close() + } + return logger.Close, nil +} + +// SetDefault installs an existing logger as the package-wide logger. +func SetDefault(logger *Logger) { + if logger == nil { + logger = &Logger{logger: slog.New(slog.NewJSONHandler(io.Discard, nil))} + } + setupMu.Lock() + previous := defaultLogger.Swap(logger) + setupMu.Unlock() + if previous != nil && previous != logger { + _ = previous.Close() + } +} + +// Close closes the current process-wide logger and disables further output. +func Close() error { + discard := &Logger{logger: slog.New(slog.NewJSONHandler(io.Discard, nil))} + setupMu.Lock() + previous := defaultLogger.Swap(discard) + setupMu.Unlock() + if previous == nil { + return nil + } + return previous.Close() +} + +// With returns a child logger carrying stable attributes. +func With(args ...any) *Logger { return defaultLogger.Load().With(args...) } + +// WithGroup returns a child logger writing attributes under a group. +func WithGroup(name string) *Logger { return defaultLogger.Load().WithGroup(name) } + +func (l *Logger) With(args ...any) *Logger { + if l == nil || l.logger == nil { + return defaultLogger.Load().With(args...) + } + return &Logger{logger: l.logger.With(args...), closer: l.closer} +} + +func (l *Logger) WithGroup(name string) *Logger { + if l == nil || l.logger == nil { + return defaultLogger.Load().WithGroup(name) + } + return &Logger{logger: l.logger.WithGroup(name), closer: l.closer} +} + +// Close releases resources owned by the root logger. It is safe to call more +// than once and is a no-op for loggers writing to standard streams. +func (l *Logger) Close() error { + if l == nil || l.closer == nil { + return nil + } + l.closer.once.Do(func() { l.closer.err = l.closer.fn() }) + return l.closer.err +} + +func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) { + ctx = normalizeContext(ctx) + l.logger.Log(ctx, slog.LevelDebug, msg, contextAttrs(ctx, args)...) +} + +func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) { + ctx = normalizeContext(ctx) + l.logger.Log(ctx, slog.LevelInfo, msg, contextAttrs(ctx, args)...) +} + +func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) { + ctx = normalizeContext(ctx) + l.logger.Log(ctx, slog.LevelWarn, msg, contextAttrs(ctx, args)...) +} + +func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) { + ctx = normalizeContext(ctx) + l.logger.Log(ctx, slog.LevelError, msg, contextAttrs(ctx, args)...) +} + +func (l *Logger) Debug(msg string, args ...any) { l.DebugContext(context.Background(), msg, args...) } +func (l *Logger) Info(msg string, args ...any) { l.InfoContext(context.Background(), msg, args...) } +func (l *Logger) Warn(msg string, args ...any) { l.WarnContext(context.Background(), msg, args...) } +func (l *Logger) Error(msg string, args ...any) { l.ErrorContext(context.Background(), msg, args...) } + +func DebugContext(ctx context.Context, msg string, args ...any) { + defaultLogger.Load().DebugContext(ctx, msg, args...) +} + +func InfoContext(ctx context.Context, msg string, args ...any) { + defaultLogger.Load().InfoContext(ctx, msg, args...) +} + +func WarnContext(ctx context.Context, msg string, args ...any) { + defaultLogger.Load().WarnContext(ctx, msg, args...) +} + +func ErrorContext(ctx context.Context, msg string, args ...any) { + defaultLogger.Load().ErrorContext(ctx, msg, args...) +} + +func Debug(msg string, args ...any) { DebugContext(context.Background(), msg, args...) } +func Info(msg string, args ...any) { InfoContext(context.Background(), msg, args...) } +func Warn(msg string, args ...any) { WarnContext(context.Background(), msg, args...) } +func Error(msg string, args ...any) { ErrorContext(context.Background(), msg, args...) } + +type contextKey uint8 + +const ( + requestIDKey contextKey = iota + 1 + traceIDKey +) + +// WithRequestID adds a request correlation ID to a context. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey, requestID) +} + +// RequestID returns the request correlation ID stored in ctx. +func RequestID(ctx context.Context) string { + if ctx == nil { + return "" + } + value, _ := ctx.Value(requestIDKey).(string) + return value +} + +// WithTraceID adds a trace correlation ID to a context. +func WithTraceID(ctx context.Context, traceID string) context.Context { + return context.WithValue(ctx, traceIDKey, traceID) +} + +// TraceID returns the trace correlation ID stored in ctx. +func TraceID(ctx context.Context) string { + if ctx == nil { + return "" + } + value, _ := ctx.Value(traceIDKey).(string) + return value +} + +func contextAttrs(ctx context.Context, args []any) []any { + ctx = normalizeContext(ctx) + attrs := make([]any, 0, len(args)+4) + if requestID := RequestID(ctx); requestID != "" { + attrs = append(attrs, "requestId", requestID) + } + if traceID := TraceID(ctx); traceID != "" { + attrs = append(attrs, "traceId", traceID) + } + return append(attrs, args...) +} + +func normalizeContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +func normalizeConfig(cfg Config) Config { + if strings.TrimSpace(cfg.Level) == "" { + cfg.Level = "info" + } + cfg.Level = strings.ToLower(strings.TrimSpace(cfg.Level)) + if strings.TrimSpace(cfg.Format) == "" { + cfg.Format = "json" + } + cfg.Format = strings.ToLower(strings.TrimSpace(cfg.Format)) + if strings.TrimSpace(cfg.Output) == "" { + cfg.Output = "stderr" + } + cfg.Output = strings.ToLower(strings.TrimSpace(cfg.Output)) + return cfg +} + +func parseLevel(value string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "debug": + return slog.LevelDebug, nil + case "info": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return 0, fmt.Errorf("logx: unsupported level %q", value) + } +} + +func openWriter(cfg Config) (io.Writer, func() error, error) { + if cfg.Writer != nil { + return cfg.Writer, nil, nil + } + if cfg.File != "" { + file, err := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return nil, nil, err + } + return file, file.Close, nil + } + switch cfg.Output { + case "stdout": + return os.Stdout, nil, nil + case "stderr": + return os.Stderr, nil, nil + case "discard", "none": + return io.Discard, nil, nil + default: + return nil, nil, errors.New("logx: output must be stdout, stderr, discard, or a file must be set") + } +} diff --git a/logx/logx_test.go b/logx/logx_test.go new file mode 100644 index 0000000..16c30b2 --- /dev/null +++ b/logx/logx_test.go @@ -0,0 +1,37 @@ +package logx_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func TestSetupAddsCorrelationFields(t *testing.T) { + var output bytes.Buffer + _, err := logx.Setup(logx.Config{Level: "info", Format: "json", Writer: &output}) + if err != nil { + t.Fatalf("setup logger: %v", err) + } + t.Cleanup(func() { _ = logx.Close() }) + + ctx := logx.WithRequestID(context.Background(), "request-123") + ctx = logx.WithTraceID(ctx, "trace-456") + logx.InfoContext(ctx, "user.created", "userId", 42) + + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatalf("decode log record: %v; output=%q", err, output.String()) + } + if record["msg"] != "user.created" || record["requestId"] != "request-123" || record["traceId"] != "trace-456" { + t.Fatalf("unexpected log record: %#v", record) + } +} + +func TestNewRejectsUnknownLevel(t *testing.T) { + if _, err := logx.New(logx.Config{Level: "verbose"}); err == nil { + t.Fatal("expected unsupported level error") + } +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..173e973 --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "fmt" + "net/http" + "time" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +// RequestIDKey is the value key used by the request ID middleware. +const RequestIDKey = "go-omni.requestId" + +// RequestID reads or creates a request ID and returns it in the response +// header. It is a transport-neutral GoOmni middleware. +func RequestID(header string) httpx.Middleware { + return func(next httpx.Handler) httpx.Handler { + return func(ctx *httpx.Context) error { + requestID := ctx.Header(header) + if requestID == "" { + requestID = newRequestID() + } + ctx.Set(RequestIDKey, requestID) + ctx.SetResponseHeader(header, requestID) + ctx.WithContext(logx.WithRequestID(ctx.Context(), requestID)) + return next(ctx) + } + } +} + +// Recovery converts panics from handlers and middleware into the standard +// internal error. The network adapter does not need a Gin-specific recovery +// hook for this behavior. +func Recovery() httpx.Middleware { + return func(next httpx.Handler) httpx.Handler { + return func(ctx *httpx.Context) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = apierrors.Internal(fmt.Errorf("panic recovered: %v", recovered)) + } + }() + return next(ctx) + } + } +} + +// AccessLog records one structured event after each request. It deliberately +// lives in GoOmni instead of using a router-specific logger so business +// applications get the same log format with every transport adapter. +func AccessLog() httpx.Middleware { + return func(next httpx.Handler) httpx.Handler { + return func(ctx *httpx.Context) error { + started := time.Now() + err := next(ctx) + status := ctx.Status() + if err != nil { + status = apierrors.From(err).Status + } + logx.InfoContext(ctx.Context(), "http.request", + "method", ctx.Request().Method, + "path", ctx.Request().URL.Path, + "route", httpx.RoutePattern(ctx.Context()), + "status", status, + "durationMs", time.Since(started).Seconds()*1000, + "clientIp", clientIP(ctx.Request()), + ) + return err + } + } +} + +func clientIP(request *http.Request) string { + if request == nil { + return "" + } + if value := request.Header.Get("X-Forwarded-For"); value != "" { + return value + } + return request.RemoteAddr +} diff --git a/middleware/request_id.go b/middleware/request_id.go new file mode 100644 index 0000000..a303270 --- /dev/null +++ b/middleware/request_id.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +func newRequestID() string { + value := make([]byte, 16) + if _, err := rand.Read(value); err == nil { + return hex.EncodeToString(value) + } + return fmt.Sprintf("%d", time.Now().UnixNano()) +} diff --git a/omni.go b/omni.go new file mode 100644 index 0000000..aa7e706 --- /dev/null +++ b/omni.go @@ -0,0 +1,67 @@ +package omni + +import ( + "time" + + "git.yuntuo.hk/k2safe/go-omni/app" + "git.yuntuo.hk/k2safe/go-omni/auth" + "git.yuntuo.hk/k2safe/go-omni/config" + 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/response" +) + +type App = app.App +type RouterGroup = app.RouterGroup +type Principal = auth.Principal +type Authenticator = auth.Authenticator +type Context = httpx.Context +type Handler = httpx.Handler +type Middleware = httpx.Middleware +type Router = httpx.Router +type HTTPRouterGroup = httpx.RouterGroup +type APIError = apierrors.APIError +type Config = config.Config +type Option = config.Option +type Response = response.Response +type LogConfig = logx.Config +type Logger = logx.Logger + +func New(options ...Option) *App { return app.New(options...) } + +func NewWithRouter(router Router, options ...Option) *App { + return app.NewWithRouter(router, options...) +} + +func WithMode(mode string) Option { return config.WithMode(mode) } + +func WithRequestIDHeader(header string) Option { return config.WithRequestIDHeader(header) } + +func WithLogger(enabled bool) Option { return config.WithLogger(enabled) } + +func WithConfig(settings Config) Option { return config.WithConfig(settings) } + +func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) } + +func WithShutdownTimeout(timeout time.Duration) Option { return config.WithShutdownTimeout(timeout) } + +func LoadConfig(prefix string) (Config, error) { return config.LoadFromEnv(prefix) } + +func SetupLogger(cfg LogConfig) (func() error, error) { return logx.Setup(cfg) } + +func NewError(status int, code, message string) *APIError { + return apierrors.New(status, code, message) +} + +func BadRequest(code, message string) *APIError { return apierrors.BadRequest(code, message) } + +func Unauthorized(code, message string) *APIError { return apierrors.Unauthorized(code, message) } + +func Forbidden(code, message string) *APIError { return apierrors.Forbidden(code, message) } + +func NotFound(code, message string) *APIError { return apierrors.NotFound(code, message) } + +func Internal(cause error) *APIError { return apierrors.Internal(cause) } + +func PrincipalFromContext(ctx *Context) (Principal, bool) { return auth.FromContext(ctx) } diff --git a/response/response.go b/response/response.go new file mode 100644 index 0000000..08bb947 --- /dev/null +++ b/response/response.go @@ -0,0 +1,9 @@ +package response + +// Response is the standard JSON envelope returned by GoOmni helpers. +type Response struct { + Code string `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` + RequestID string `json:"requestId,omitempty"` +} diff --git a/router_test.go b/router_test.go new file mode 100644 index 0000000..bccedc6 --- /dev/null +++ b/router_test.go @@ -0,0 +1,69 @@ +package omni + +import ( + "net/http" + "net/http/httptest" + "testing" + + "git.yuntuo.hk/k2safe/go-omni/httpx" +) + +func TestNewWithRouterKeepsHandlersTransportNeutral(t *testing.T) { + router := &testRouter{} + app := NewWithRouter(router) + app.GET("/health", func(ctx *Context) error { + if ctx.RequestID() == "" { + t.Fatal("expected request ID from core middleware") + } + return ctx.OK(map[string]string{"status": "ok"}) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/health", nil) + app.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", recorder.Code) + } +} + +type testRouter struct { + handlers map[string]http.Handler +} + +func (r *testRouter) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if handler := r.handlers[request.Method+" "+request.URL.Path]; handler != nil { + handler.ServeHTTP(writer, request) + return + } + http.NotFound(writer, request) +} + +func (r *testRouter) Handle(method, path string, handler http.Handler) { + if r.handlers == nil { + r.handlers = make(map[string]http.Handler) + } + r.handlers[method+" "+path] = handler +} + +func (r *testRouter) Any(path string, handler http.Handler) { r.Handle(http.MethodGet, path, handler) } + +func (r *testRouter) Group(path string) httpx.RouterGroup { + return &testRouterGroup{router: r, prefix: path} +} + +type testRouterGroup struct { + router *testRouter + prefix string +} + +func (g *testRouterGroup) Handle(method, path string, handler http.Handler) { + g.router.Handle(method, g.prefix+path, handler) +} + +func (g *testRouterGroup) Any(path string, handler http.Handler) { + g.Handle(http.MethodGet, path, handler) +} + +func (g *testRouterGroup) Group(path string) httpx.RouterGroup { + return &testRouterGroup{router: g.router, prefix: g.prefix + path} +} diff --git a/transport/README.md b/transport/README.md new file mode 100644 index 0000000..d3bb1ef --- /dev/null +++ b/transport/README.md @@ -0,0 +1,22 @@ +# Transport adapters + +GoOmni 的业务处理链依赖 `httpx.Router`,不直接依赖具体网络框架。当前的 `transport/gin` 只负责把 Gin 的路由、路径参数和响应写入转换成标准 HTTP 接口;`transport/nethttp` 提供了一个只使用标准库的参考实现。 + +使用标准库适配器: + +```go +import ( + omni "git.yuntuo.hk/k2safe/go-omni" + netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" +) + +application := omni.NewWithRouter(netrouter.New()) +``` + +后续自研网络层时,实现以下能力即可接入现有 `app`、中间件和业务 Handler: + +- `httpx.Router`:注册路由、分组并实现 `http.Handler`。 +- `httpx.RouterGroup`:注册分组内路由。 +- 适配器为请求 context 注入 `httpx.WithRouteParams` 和 `httpx.WithRoutePattern`。 + +业务层不应该导入具体 transport 包;它们只是网络层实现。 diff --git a/transport/gin/router.go b/transport/gin/router.go new file mode 100644 index 0000000..b4f7248 --- /dev/null +++ b/transport/gin/router.go @@ -0,0 +1,70 @@ +// Package gin adapts Gin to GoOmni's transport-neutral router contract. +// Business packages should import httpx and app, not this package. +package gin + +import ( + "net/http" + + "git.yuntuo.hk/k2safe/go-omni/httpx" + ginlib "github.com/gin-gonic/gin" +) + +// Router is the default Gin-backed GoOmni router. +type Router struct { + engine *ginlib.Engine +} + +// New creates a Gin router without Gin's default logger or recovery middleware. +func New(mode string) *Router { + if mode != "" { + ginlib.SetMode(mode) + } + return &Router{engine: ginlib.New()} +} + +// NewWithEngine wraps an existing Gin engine for incremental migration. +func NewWithEngine(engine *ginlib.Engine) *Router { return &Router{engine: engine} } + +// Engine exposes the underlying Gin engine to adapter-specific integrations. +func (r *Router) Engine() *ginlib.Engine { return r.engine } + +func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + r.engine.ServeHTTP(writer, request) +} + +func (r *Router) Handle(method, path string, handler http.Handler) { + r.engine.Handle(method, path, r.adapt(handler)) +} + +func (r *Router) Any(path string, handler http.Handler) { r.engine.Any(path, r.adapt(handler)) } + +func (r *Router) Group(path string) httpx.RouterGroup { + return &group{group: r.engine.Group(path)} +} + +type group struct{ group *ginlib.RouterGroup } + +func (g *group) Handle(method, path string, handler http.Handler) { + g.group.Handle(method, path, adapt(handler)) +} + +func (g *group) Any(path string, handler http.Handler) { g.group.Any(path, adapt(handler)) } + +func (g *group) Group(path string) httpx.RouterGroup { + return &group{group: g.group.Group(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)) + for _, param := range ctx.Params { + params[param.Key] = param.Value + } + requestContext := httpx.WithRouteParams(ctx.Request.Context(), params) + requestContext = httpx.WithRoutePattern(requestContext, ctx.FullPath()) + request := ctx.Request.WithContext(requestContext) + handler.ServeHTTP(ctx.Writer, request) + } +} diff --git a/transport/nethttp/router.go b/transport/nethttp/router.go new file mode 100644 index 0000000..41eca49 --- /dev/null +++ b/transport/nethttp/router.go @@ -0,0 +1,218 @@ +// 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 + +import ( + "net/http" + "sort" + "strings" + "sync" + + "git.yuntuo.hk/k2safe/go-omni/httpx" +) + +// Router implements httpx.Router with net/http only. Register routes before +// serving requests. +type Router struct { + mu sync.RWMutex + routes []route +} + +type route struct { + method string + pattern string + segments []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) + if !matched { + continue + } + pathMatched = true + if candidate.method != "*" && candidate.method != request.Method { + allow[candidate.method] = struct{}{} + continue + } + if selected == nil || candidate.score > selected.score { + selected = candidate + selectedParams = params + } + } + + if selected == nil { + if pathMatched { + methods := make([]string, 0, len(allow)) + for method := range allow { + methods = append(methods, method) + } + sort.Strings(methods) + writer.Header().Set("Allow", strings.Join(methods, ", ")) + http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + http.NotFound(writer, request) + return + } + + requestContext := httpx.WithRouteParams(request.Context(), selectedParams) + requestContext = httpx.WithRoutePattern(requestContext, selected.pattern) + selected.handler.ServeHTTP(writer, request.WithContext(requestContext)) +} + +func (r *Router) Handle(method, path string, handler http.Handler) { + if handler == nil { + panic("go-omni/nethttp: nil route handler") + } + 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() +} + +func (r *Router) Any(path string, handler http.Handler) { r.Handle("*", path, handler) } + +func (r *Router) Group(path string) httpx.RouterGroup { + return &group{router: r, prefix: cleanPattern(path)} +} + +type group struct { + router *Router + prefix string +} + +func (g *group) Handle(method, path string, handler http.Handler) { + g.router.Handle(method, joinPattern(g.prefix, path), handler) +} + +func (g *group) Any(path string, handler http.Handler) { g.Handle("*", path, handler) } + +func (g *group) Group(path string) httpx.RouterGroup { + return &group{router: g.router, prefix: joinPattern(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}) + } + } + 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 +} diff --git a/transport/nethttp/router_test.go b/transport/nethttp/router_test.go new file mode 100644 index 0000000..03ecfb6 --- /dev/null +++ b/transport/nethttp/router_test.go @@ -0,0 +1,57 @@ +package nethttp_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + omni "git.yuntuo.hk/k2safe/go-omni" + netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" +) + +func TestRouterWorksWithoutGin(t *testing.T) { + router := netrouter.New() + app := omni.NewWithRouter(router) + app.GET("/users/:id", func(ctx *omni.Context) error { + return ctx.OK(map[string]string{"id": ctx.Param("id")}) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/users/42", nil) + app.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String()) + } + var body struct { + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Data["id"] != "42" { + t.Fatalf("unexpected route parameter: %#v", body.Data) + } +} + +func TestRouterPrefersStaticRoutesAndReturnsMethodNotAllowed(t *testing.T) { + router := netrouter.New() + router.Handle(http.MethodGet, "/users/:id", http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusAccepted) + })) + router.Handle(http.MethodGet, "/users/me", http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusCreated) + })) + + static := httptest.NewRecorder() + router.ServeHTTP(static, httptest.NewRequest(http.MethodGet, "/users/me", nil)) + if static.Code != http.StatusCreated { + t.Fatalf("static route did not win, got %d", static.Code) + } + + method := httptest.NewRecorder() + router.ServeHTTP(method, httptest.NewRequest(http.MethodPost, "/users/me", nil)) + if method.Code != http.StatusMethodNotAllowed || method.Header().Get("Allow") != http.MethodGet { + t.Fatalf("unexpected method response: status=%d allow=%q", method.Code, method.Header().Get("Allow")) + } +} diff --git a/validate/validate.go b/validate/validate.go new file mode 100644 index 0000000..a38c874 --- /dev/null +++ b/validate/validate.go @@ -0,0 +1,159 @@ +package validate + +import ( + "encoding/json" + "net/http" + "reflect" + "strconv" + "strings" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + + "github.com/go-playground/validator/v10" +) + +var structValidator = newValidator() + +func newValidator() *validator.Validate { + instance := validator.New() + instance.SetTagName("binding") + return instance +} + +// JSON binds and validates a JSON request body using standard-library HTTP +// types. It intentionally does not depend on Gin. +func JSON(request *http.Request, value any) error { + if request == nil || request.Body == nil { + return apierrors.BadRequest("INVALID_BODY", "request body is invalid") + } + if err := json.NewDecoder(request.Body).Decode(value); err != nil { + return apierrors.BadRequest("INVALID_BODY", "request body is invalid").WithCause(err) + } + return validateValue(value, "INVALID_BODY", "request body is invalid") +} + +// Query binds query parameters using form tags and validates the result. +func Query(request *http.Request, value any) error { + if request == nil { + return apierrors.BadRequest("INVALID_QUERY", "query parameters are invalid") + } + if err := bindValues(request.URL.Query(), value, "form"); err != nil { + return apierrors.BadRequest("INVALID_QUERY", "query parameters are invalid").WithCause(err) + } + return validateValue(value, "INVALID_QUERY", "query parameters are invalid") +} + +// URI binds path parameters supplied by a router adapter and validates them. +func URI(params map[string]string, value any) error { + if err := bindValues(singleValues(params), value, "uri"); err != nil { + return apierrors.BadRequest("INVALID_PATH", "path parameters are invalid").WithCause(err) + } + return validateValue(value, "INVALID_PATH", "path parameters are invalid") +} + +func validateValue(value any, code, message string) error { + if err := structValidator.Struct(value); err != nil { + return apierrors.BadRequest(code, message).WithCause(err) + } + return nil +} + +func bindValues(values map[string][]string, target any, tagName string) error { + value := reflect.ValueOf(target) + if value.Kind() != reflect.Pointer || value.IsNil() || value.Elem().Kind() != reflect.Struct { + return &bindError{"target must be a non-nil pointer to a struct"} + } + return bindStruct(values, value.Elem(), tagName) +} + +func bindStruct(values map[string][]string, target reflect.Value, tagName string) error { + typeOfTarget := target.Type() + for index := 0; index < target.NumField(); index++ { + field := target.Field(index) + if !field.CanSet() { + continue + } + fieldInfo := typeOfTarget.Field(index) + tag := fieldInfo.Tag.Get(tagName) + if tag == "-" { + continue + } + name, defaultValue := parseTag(tag, fieldInfo.Name) + items, ok := values[name] + if !ok || len(items) == 0 || items[0] == "" { + if defaultValue != "" { + items = []string{defaultValue} + } else { + continue + } + } + if err := setValue(field, items[0]); err != nil { + return &bindError{fieldInfo.Name + ": " + err.Error()} + } + } + return nil +} + +func parseTag(tag, fallback string) (string, string) { + if tag == "" { + return fallback, "" + } + parts := strings.Split(tag, ",") + name := parts[0] + if name == "" { + name = fallback + } + var defaultValue string + for _, part := range parts[1:] { + if strings.HasPrefix(part, "default=") { + defaultValue = strings.TrimPrefix(part, "default=") + } + } + return name, defaultValue +} + +func setValue(target reflect.Value, value string) error { + switch target.Kind() { + case reflect.String: + target.SetString(value) + case reflect.Bool: + parsed, err := strconv.ParseBool(value) + if err != nil { + return err + } + target.SetBool(parsed) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + parsed, err := strconv.ParseInt(value, 10, target.Type().Bits()) + if err != nil { + return err + } + target.SetInt(parsed) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + parsed, err := strconv.ParseUint(value, 10, target.Type().Bits()) + if err != nil { + return err + } + target.SetUint(parsed) + case reflect.Float32, reflect.Float64: + parsed, err := strconv.ParseFloat(value, target.Type().Bits()) + if err != nil { + return err + } + target.SetFloat(parsed) + default: + return &bindError{"unsupported field type " + target.Type().String()} + } + return nil +} + +func singleValues(values map[string]string) map[string][]string { + result := make(map[string][]string, len(values)) + for key, value := range values { + result[key] = []string{value} + } + return result +} + +type bindError struct{ message string } + +func (e *bindError) Error() string { return e.message }