feat: bootstrap reusable GoOmni framework
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
coverage.out
|
||||||
157
README.md
Normal file
157
README.md
Normal file
@@ -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`,具体驱动、迁移和表结构由项目选择。
|
||||||
166
app/app.go
Normal file
166
app/app.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
107
app/router.go
Normal file
107
app/router.go
Normal file
@@ -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...))
|
||||||
|
}
|
||||||
132
app_test.go
Normal file
132
app_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
auth/README.md
Normal file
36
auth/README.md
Normal file
@@ -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`。
|
||||||
145
auth/auth.go
Normal file
145
auth/auth.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
122
auth/auth_test.go
Normal file
122
auth/auth_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
cmd/README.md
Normal file
3
cmd/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# cmd
|
||||||
|
|
||||||
|
命令行工具入口。目前提供 `go-omni new`,用于生成 Handler / Service / Repository / Model 分层的服务骨架。
|
||||||
11
cmd/go-omni/README.md
Normal file
11
cmd/go-omni/README.md
Normal file
@@ -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` 才会覆盖脚手架文件。
|
||||||
85
cmd/go-omni/main.go
Normal file
85
cmd/go-omni/main.go
Normal file
@@ -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 <module>] [--force] <directory>")
|
||||||
|
}
|
||||||
184
cmd/go-omni/scaffold/scaffold.go
Normal file
184
cmd/go-omni/scaffold/scaffold.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
97
cmd/go-omni/scaffold/scaffold_test.go
Normal file
97
cmd/go-omni/scaffold/scaffold_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
cmd/go-omni/scaffold/templates/README.md.tmpl
Normal file
22
cmd/go-omni/scaffold/templates/README.md.tmpl
Normal file
@@ -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`.
|
||||||
49
cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl
Normal file
49
cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
cmd/go-omni/scaffold/templates/go.mod.tmpl
Normal file
8
cmd/go-omni/scaffold/templates/go.mod.tmpl
Normal file
@@ -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}}
|
||||||
31
cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl
Normal file
31
cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl
Normal file
@@ -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)
|
||||||
|
}
|
||||||
16
cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl
Normal file
16
cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
21
cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl
Normal file
21
cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
93
config/config.go
Normal file
93
config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
config/config_test.go
Normal file
95
config/config_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
config/env.go
Normal file
67
config/env.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
37
database/README.md
Normal file
37
database/README.md
Normal file
@@ -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 属于业务项目或可选模块,不会被核心包自动执行。
|
||||||
175
database/database.go
Normal file
175
database/database.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
58
database/database_test.go
Normal file
58
database/database_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
errors/errors.go
Normal file
74
errors/errors.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
20
examples/basic/main.go
Normal file
20
examples/basic/main.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
159
examples/reference/README.md
Normal file
159
examples/reference/README.md
Normal file
@@ -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 测试不自动连接外部数据库。
|
||||||
31
examples/reference/handler/middleware.go
Normal file
31
examples/reference/handler/middleware.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
examples/reference/handler/user.go
Normal file
121
examples/reference/handler/user.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
100
examples/reference/main.go
Normal file
100
examples/reference/main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
277
examples/reference/main_test.go
Normal file
277
examples/reference/main_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
examples/reference/model/errors.go
Normal file
11
examples/reference/model/errors.go
Normal file
@@ -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")
|
||||||
|
)
|
||||||
25
examples/reference/model/user.go
Normal file
25
examples/reference/model/user.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
126
examples/reference/repository/memory.go
Normal file
126
examples/reference/repository/memory.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
40
examples/reference/repository/memory_test.go
Normal file
40
examples/reference/repository/memory_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
examples/reference/repository/mysql.go
Normal file
122
examples/reference/repository/mysql.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
9
examples/reference/schema.sql
Normal file
9
examples/reference/schema.sql
Normal file
@@ -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)
|
||||||
|
);
|
||||||
71
examples/reference/service/user.go
Normal file
71
examples/reference/service/user.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
38
go.mod
Normal file
38
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
93
go.sum
Normal file
93
go.sum
Normal file
@@ -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=
|
||||||
17
health/README.md
Normal file
17
health/README.md
Normal file
@@ -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`。
|
||||||
153
health/health.go
Normal file
153
health/health.go
Normal file
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
73
health/health_test.go
Normal file
73
health/health_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
httpx/context.go
Normal file
227
httpx/context.go
Normal file
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
31
logx/README.md
Normal file
31
logx/README.md
Normal file
@@ -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 导出。
|
||||||
327
logx/logx.go
Normal file
327
logx/logx.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
37
logx/logx_test.go
Normal file
37
logx/logx_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
82
middleware/middleware.go
Normal file
82
middleware/middleware.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
16
middleware/request_id.go
Normal file
16
middleware/request_id.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
67
omni.go
Normal file
67
omni.go
Normal file
@@ -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) }
|
||||||
9
response/response.go
Normal file
9
response/response.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
69
router_test.go
Normal file
69
router_test.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
22
transport/README.md
Normal file
22
transport/README.md
Normal file
@@ -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 包;它们只是网络层实现。
|
||||||
70
transport/gin/router.go
Normal file
70
transport/gin/router.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
218
transport/nethttp/router.go
Normal file
218
transport/nethttp/router.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
57
transport/nethttp/router_test.go
Normal file
57
transport/nethttp/router_test.go
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
159
validate/validate.go
Normal file
159
validate/validate.go
Normal file
@@ -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 }
|
||||||
Reference in New Issue
Block a user