feat: bootstrap reusable GoOmni framework
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user