feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
6
examples/userService/.gitignore
vendored
Normal file
6
examples/userService/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/server
|
||||
/var/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.goOmni/api.lock
|
||||
19
examples/userService/.goOmni/apiManifest.json
Normal file
19
examples/userService/.goOmni/apiManifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"profile": {
|
||||
"database": "mysql",
|
||||
"storage": "local",
|
||||
"logOutput": "file",
|
||||
"logFormat": "json"
|
||||
},
|
||||
"version": 1,
|
||||
"module": "example.com/userService",
|
||||
"entry": "api/user.api",
|
||||
"generated": {
|
||||
"docs/openapi.json": "0675e4eb1278b36828aa31b12587b0d33d2f384112f67a43179eb72b52c3a0ea",
|
||||
"internal/handler/openapiGen.go": "8ddc60c338630db915b9f074ade776f526eabf60fc99b33e425359fd415c4926",
|
||||
"internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
|
||||
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
|
||||
"internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
|
||||
"internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876"
|
||||
}
|
||||
}
|
||||
79
examples/userService/README.md
Normal file
79
examples/userService/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# API 驱动的用户管理示例
|
||||
|
||||
这个独立 Go module 从 `api/user.api` 生成,随后在开发者拥有的文件中实现业务。
|
||||
使用 MySQL 持久化、事务审计、环境变量 Token 鉴权、文件日志和 local 存储;业务层只接收标准 context 和基础模块接口。
|
||||
|
||||
## 启动
|
||||
|
||||
先准备空的业务数据库,再设置环境变量(替换占位值,密钥不写进配置或 Git):
|
||||
|
||||
```sh
|
||||
export GO_OMNI_DB_DSN='user:password@tcp(127.0.0.1:3306)/user_service?parseTime=true'
|
||||
export USER_SERVICE_API_TOKEN='<至少32字节的随机值>'
|
||||
export GO_OMNI_STORAGE_SIGNING_KEY='<另一个至少32字节的随机值>'
|
||||
export GO_OMNI_STORAGE_PUBLIC_URL='http://127.0.0.1:8080/storage/object'
|
||||
go mod tidy
|
||||
# 在本目录运行与框架版本一致的 CLI;迁移必须显式执行。
|
||||
go-omni migrate --dir migrations --apply
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
源码示例的 go.mod 使用 `replace ... => ../..`。复制到独立仓库时,删除这条 replace,
|
||||
保留已发布的固定依赖版本,再执行 go mod tidy。远程版本验证脚本会自动检查没有本地替换。
|
||||
`GO_OMNI_TRANSPORT=native` 可切换为 native,业务代码无需调整。
|
||||
|
||||
配置是默认值 → etc/config.yaml → 环境变量。日志默认写入 var/log/server.log 并轮转,
|
||||
`GO_OMNI_LOG_OUTPUT=stderr` 可以切换为标准错误输出。MySQL 默认最多 10 条连接。
|
||||
应用检查 users 和 user_audit 表,缺失时拒绝启动,不自动改表。
|
||||
|
||||
## 接口
|
||||
|
||||
除健康检查、OpenAPI 和持有效签名的对象下载外,业务接口需要 `Authorization: Bearer <Token>`。
|
||||
这个 Token 代表示例的管理员;可通过同一个命名中间件槽位替换为 JWT/OIDC 和业务授权策略。
|
||||
框架不会推断租户模型或自动给接口放行。
|
||||
|
||||
| 接口 | 行为 |
|
||||
| --- | --- |
|
||||
| POST /api/v1/users | 创建,返回 201;邮箱规范化并唯一 |
|
||||
| GET /api/v1/users/:id | 查询,缺失返回 404 |
|
||||
| GET /api/v1/users?page=1&pageSize=20 | 分页,pageSize 最大 100,空结果返回 [] |
|
||||
| PUT /api/v1/users/:id | 完整更新;邮箱冲突返回 409 |
|
||||
| DELETE /api/v1/users/:id | 删除,返回 204 |
|
||||
| POST /api/v1/users/:id/export | 私有文本导出,返回 5 分钟下载链接 |
|
||||
| GET /health/ready | 数据库不可用时返回 503,恢复后重新就绪 |
|
||||
| GET /openapi.json | 与 docs/openapi.json 一致 |
|
||||
|
||||
参数不合法返回 400,认证失败 401,邮箱重复 409,内部故障只返回通用 500。
|
||||
OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上表。
|
||||
|
||||
创建、更新、删除都会在**同一个 MySQL 事务**写入 user_audit;审计写入失败时业务变更一起回滚。
|
||||
事务提交后才写成功业务日志,requestId 同时出现在 HTTP 响应、业务日志和审计记录。
|
||||
数据库异常日志不记录 SQL、DSN 或用户输入。
|
||||
|
||||
文件导出演示 `deps.Uploads` 和 `deps.Downloads`。对象使用随机 key,签名 URL 不进入日志。
|
||||
签名过期只限制下载,不删除文件;实际项目应给 exports/ 配置保留期限或清理任务。
|
||||
只签发下载链接,不对外开放任意对象上传或签名接口。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```sh
|
||||
go-omni api generate --api api/user.api --dir .
|
||||
go-omni api generate --api api/user.api --dir . --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Gen.go 和 docs/openapi.json 由生成器维护;Service、Repository、迁移、鉴权、配置和 main 保留。
|
||||
修改 .api 的类型/方法签名后,需要同步修改自己的业务实现并运行测试。
|
||||
|
||||
在框架仓库运行:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyAPIWorkflow.py
|
||||
# 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。
|
||||
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview \
|
||||
--report performance/apiWorkflowRemote.json
|
||||
```
|
||||
|
||||
验收创建只绑定 localhost 的临时 MySQL 容器(1 CPU / 768 MiB),使用随机密钥,结束时删除。
|
||||
覆盖 nethttp/native 的 CRUD、校验、鉴权、事务错误/异常/取消回滚、数据库停机恢复、
|
||||
重启持久化、签名文件下载、文件日志与优雅退出。不会连接业务服务器。
|
||||
60
examples/userService/api/user.api
Normal file
60
examples/userService/api/user.api
Normal file
@@ -0,0 +1,60 @@
|
||||
syntax = "v1"
|
||||
info (
|
||||
title: "User service reference"
|
||||
version: "1.0.0"
|
||||
)
|
||||
type (
|
||||
UserResp {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
CreateUserReq {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
UserIDReq {
|
||||
ID int64 `path:"id,range=[1:9007199254740991]"`
|
||||
}
|
||||
UpdateUserReq {
|
||||
ID int64 `path:"id,range=[1:9007199254740991]"`
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
ListUsersReq {
|
||||
Page int `form:"page,default=1,range=[1:1000000]"`
|
||||
PageSize int `form:"pageSize,default=20,range=[1:100]"`
|
||||
}
|
||||
UserPageResp {
|
||||
Items []UserResp `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
ExportResp {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
}
|
||||
)
|
||||
@server (
|
||||
group: user
|
||||
prefix: /api/v1
|
||||
middleware: Auth
|
||||
)
|
||||
service user-api {
|
||||
@handler createUser
|
||||
@status 201
|
||||
post /users (CreateUserReq) returns (UserResp)
|
||||
@handler getUser
|
||||
get /users/:id (UserIDReq) returns (UserResp)
|
||||
@handler listUsers
|
||||
get /users (ListUsersReq) returns (UserPageResp)
|
||||
@handler updateUser
|
||||
put /users/:id (UpdateUserReq) returns (UserResp)
|
||||
@handler deleteUser
|
||||
delete /users/:id (UserIDReq)
|
||||
@doc "Export one user as a private text object with a short-lived download link"
|
||||
@handler exportUser
|
||||
post /users/:id/export (UserIDReq) returns (ExportResp)
|
||||
}
|
||||
58
examples/userService/cmd/server/main.go
Normal file
58
examples/userService/cmd/server/main.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/userService/internal/handler"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"example.com/userService/internal/settings"
|
||||
"flag"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := flag.String("config", "etc/config.yaml", "configuration file; empty uses defaults and environment")
|
||||
flag.Parse()
|
||||
if err := run(*path); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
func run(path string) (runErr error) {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
cfg, err := settings.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger, err := logx.New(cfg.Server.Log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { runErr = errors.Join(runErr, logger.Close()) }()
|
||||
logx.SetDefault(logger)
|
||||
deps, err := servicecontext.New(ctx, logger, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { runErr = errors.Join(runErr, deps.Close()) }()
|
||||
app := omni.New(omni.WithConfig(cfg.Server))
|
||||
app.OnShutdown(logger.Close)
|
||||
app.OnShutdown(deps.Close)
|
||||
for _, method := range []string{"GET", "HEAD", "PUT"} {
|
||||
app.Handle(method, "/storage/object", func(c *httpx.Context) error { deps.FileHTTP.ServeHTTP(c.Raw(), c.Request()); return nil })
|
||||
}
|
||||
if err = handler.RegisterRoutes(app, deps); err != nil {
|
||||
return err
|
||||
}
|
||||
app.GET("/openapi.json", handler.OpenAPI)
|
||||
app.GET("/health/live", deps.Health.LivenessHandler())
|
||||
app.GET("/health/ready", deps.Health.ReadinessHandler())
|
||||
logger.InfoContext(ctx, "service.starting", "address", cfg.Server.Address)
|
||||
return app.RunContext(ctx, cfg.Server.Address)
|
||||
}
|
||||
1249
examples/userService/docs/openapi.json
Normal file
1249
examples/userService/docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
37
examples/userService/etc/config.yaml
Normal file
37
examples/userService/etc/config.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
# Non-secret settings only. Environment variables override these values.
|
||||
server:
|
||||
address: '127.0.0.1:8080'
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
log:
|
||||
service: "example.com/userService"
|
||||
level: info
|
||||
format: json
|
||||
file: './var/log/server.log'
|
||||
rotation:
|
||||
enabled: true
|
||||
maxSizeMb: 100
|
||||
maxBackups: 10
|
||||
maxAgeDays: 7
|
||||
compress: true
|
||||
redactKeys: []
|
||||
storage:
|
||||
provider: local
|
||||
maxBytes: 33554432
|
||||
contentTypes: ['text/plain', 'image/png', 'image/jpeg', 'application/pdf']
|
||||
linkTtl: 5m
|
||||
requestTimeout: 30s
|
||||
root: './var/files'
|
||||
publicUrl: 'http://127.0.0.1:8080/storage/object'
|
||||
|
||||
|
||||
database:
|
||||
driver: mysql
|
||||
maxOpenConns: 10
|
||||
maxIdleConns: 5
|
||||
connMaxLifetime: 30m
|
||||
connMaxIdleTime: 5m
|
||||
pingTimeout: 5s
|
||||
# GO_OMNI_DB_DSN supplies the secret connection string.
|
||||
|
||||
26
examples/userService/go.mod
Normal file
26
examples/userService/go.mod
Normal file
@@ -0,0 +1,26 @@
|
||||
module example.com/userService
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
git.yuntuo.hk/k2safe/go-omni v0.7.0-preview
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace git.yuntuo.hk/k2safe/go-omni => ../..
|
||||
38
examples/userService/go.sum
Normal file
38
examples/userService/go.sum
Normal file
@@ -0,0 +1,38 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
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/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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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/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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
13
examples/userService/internal/handler/openapiGen.go
Normal file
13
examples/userService/internal/handler/openapiGen.go
Normal file
File diff suppressed because one or more lines are too long
27
examples/userService/internal/handler/routesGen.go
Normal file
27
examples/userService/internal/handler/routesGen.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package handler
|
||||
|
||||
import (
|
||||
h0 "example.com/userService/internal/handler/user"
|
||||
s0 "example.com/userService/internal/service/user"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {
|
||||
if router == nil || deps == nil {
|
||||
return fmt.Errorf("router and dependencies required")
|
||||
}
|
||||
if deps.Middlewares["Auth"] == nil {
|
||||
return fmt.Errorf("missing middleware Auth")
|
||||
}
|
||||
h0 := h0.New(s0.New(deps))
|
||||
router.Handle("POST", "/api/v1/users", h0.CreateUser, deps.Middlewares["Auth"])
|
||||
router.Handle("GET", "/api/v1/users/:id", h0.GetUser, deps.Middlewares["Auth"])
|
||||
router.Handle("GET", "/api/v1/users", h0.ListUsers, deps.Middlewares["Auth"])
|
||||
router.Handle("PUT", "/api/v1/users/:id", h0.UpdateUser, deps.Middlewares["Auth"])
|
||||
router.Handle("DELETE", "/api/v1/users/:id", h0.DeleteUser, deps.Middlewares["Auth"])
|
||||
router.Handle("POST", "/api/v1/users/:id/export", h0.ExportUser, deps.Middlewares["Auth"])
|
||||
return nil
|
||||
}
|
||||
120
examples/userService/internal/handler/user/handlersGen.go
Normal file
120
examples/userService/internal/handler/user/handlersGen.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/types"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
CreateUser(ctx context.Context, req *types.CreateUserReq) (*types.UserResp, error)
|
||||
GetUser(ctx context.Context, req *types.UserIDReq) (*types.UserResp, error)
|
||||
ListUsers(ctx context.Context, req *types.ListUsersReq) (*types.UserPageResp, error)
|
||||
UpdateUser(ctx context.Context, req *types.UpdateUserReq) (*types.UserResp, error)
|
||||
DeleteUser(ctx context.Context, req *types.UserIDReq) error
|
||||
ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error)
|
||||
}
|
||||
type Handler struct{ service Service }
|
||||
|
||||
func New(s Service) *Handler { return &Handler{service: s} }
|
||||
|
||||
var createUserRequest = contract.MustNew[types.CreateUserReq]()
|
||||
|
||||
func (h *Handler) CreateUser(c *httpx.Context) error {
|
||||
var req types.CreateUserReq
|
||||
if err := createUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.CreateUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("CreateUser returned a nil response")
|
||||
}
|
||||
return c.Created(resp)
|
||||
}
|
||||
|
||||
var getUserRequest = contract.MustNew[types.UserIDReq]()
|
||||
|
||||
func (h *Handler) GetUser(c *httpx.Context) error {
|
||||
var req types.UserIDReq
|
||||
if err := getUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.GetUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("GetUser returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
|
||||
var listUsersRequest = contract.MustNew[types.ListUsersReq]()
|
||||
|
||||
func (h *Handler) ListUsers(c *httpx.Context) error {
|
||||
var req types.ListUsersReq
|
||||
if err := listUsersRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.ListUsers(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("ListUsers returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
|
||||
var updateUserRequest = contract.MustNew[types.UpdateUserReq]()
|
||||
|
||||
func (h *Handler) UpdateUser(c *httpx.Context) error {
|
||||
var req types.UpdateUserReq
|
||||
if err := updateUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.UpdateUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("UpdateUser returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
|
||||
var deleteUserRequest = contract.MustNew[types.UserIDReq]()
|
||||
|
||||
func (h *Handler) DeleteUser(c *httpx.Context) error {
|
||||
var req types.UserIDReq
|
||||
if err := deleteUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.service.DeleteUser(c.Context(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.NoContent()
|
||||
}
|
||||
|
||||
var exportUserRequest = contract.MustNew[types.UserIDReq]()
|
||||
|
||||
func (h *Handler) ExportUser(c *httpx.Context) error {
|
||||
var req types.UserIDReq
|
||||
if err := exportUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.ExportUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("ExportUser returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
40
examples/userService/internal/middleware/auth.go
Normal file
40
examples/userService/internal/middleware/auth.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Auth is the fail-closed registry default. Startup replaces it with configured
|
||||
// authentication. This administrative example uses one environment-only key;
|
||||
// production applications can inject JWT/OIDC through the same middleware slot.
|
||||
func Auth(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
}
|
||||
}
|
||||
func NewAuthFromEnv() (httpx.Middleware, error) {
|
||||
token := os.Getenv("USER_SERVICE_API_TOKEN")
|
||||
if len(token) < 32 || strings.ContainsAny(token, " \t\r\n") {
|
||||
return nil, errors.New("USER_SERVICE_API_TOKEN requires at least 32 bytes without whitespace")
|
||||
}
|
||||
expected := sha256.Sum256([]byte(token))
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
parts := strings.Fields(c.Header("Authorization"))
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
}
|
||||
actual := sha256.Sum256([]byte(parts[1]))
|
||||
if subtle.ConstantTimeCompare(expected[:], actual[:]) != 1 {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
35
examples/userService/internal/middleware/auth_test.go
Normal file
35
examples/userService/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthFailsClosedAndDoesNotLeakKey(t *testing.T) {
|
||||
t.Setenv("USER_SERVICE_API_TOKEN", "")
|
||||
if _, err := NewAuthFromEnv(); err == nil {
|
||||
t.Fatal("missing credential accepted")
|
||||
}
|
||||
token := "test-key-012345678901234567890123456789"
|
||||
t.Setenv("USER_SERVICE_API_TOKEN", token)
|
||||
middleware, err := NewAuthFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, header := range []string{"", "Basic " + token, "Bearer wrong", "Bearer " + token} {
|
||||
called := false
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Authorization", header)
|
||||
c := httpx.NewContext(r, httptest.NewRecorder(), nil)
|
||||
err = middleware(func(*httpx.Context) error { called = true; return nil })(c)
|
||||
if header == "Bearer "+token {
|
||||
if err != nil || !called {
|
||||
t.Fatal("valid token rejected")
|
||||
}
|
||||
} else if err == nil || called || apierrors.From(err).Status != 401 {
|
||||
t.Fatal("unauthenticated request reached service")
|
||||
}
|
||||
}
|
||||
}
|
||||
10
examples/userService/internal/middleware/registryGen.go
Normal file
10
examples/userService/internal/middleware/registryGen.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package middleware
|
||||
|
||||
import "git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
|
||||
func Defaults() map[string]httpx.Middleware {
|
||||
return map[string]httpx.Middleware{
|
||||
"Auth": Auth,
|
||||
}
|
||||
}
|
||||
11
examples/userService/internal/model/errors.go
Normal file
11
examples/userService/internal/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/userService/internal/model/user.go
Normal file
25
examples/userService/internal/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"`
|
||||
}
|
||||
143
examples/userService/internal/repository/user/mysql.go
Normal file
143
examples/userService/internal/repository/user/mysql.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"example.com/userService/internal/model"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// SQLExecutor is implemented by both *sql.DB and *sql.Tx. A repository created
|
||||
// with a transaction keeps all of its queries on that same transaction.
|
||||
type SQLExecutor interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
// MySQLUsers is the persistent implementation of UserRepository.
|
||||
// The schema is documented in schema.sql; migrations stay outside the framework.
|
||||
type MySQLUsers struct {
|
||||
db SQLExecutor
|
||||
}
|
||||
|
||||
func NewMySQLUsers(db SQLExecutor) *MySQLUsers {
|
||||
return &MySQLUsers{db: db}
|
||||
}
|
||||
|
||||
// CheckSchema validates that the application's migration has been applied.
|
||||
// Runtime startup never creates or alters tables.
|
||||
func (r *MySQLUsers) CheckSchema(ctx context.Context) error {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, name, email, created_at, updated_at FROM users LIMIT 0`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rows.Close()
|
||||
}
|
||||
|
||||
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) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
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
|
||||
}
|
||||
16
examples/userService/internal/repository/user/repository.go
Normal file
16
examples/userService/internal/repository/user/repository.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/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
|
||||
}
|
||||
41
examples/userService/internal/repository/user/store.go
Normal file
41
examples/userService/internal/repository/user/store.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UnitOfWork interface {
|
||||
UserRepository
|
||||
WithTx(context.Context, func(UserRepository, func(string, int64, string) error) error) error
|
||||
}
|
||||
type Store struct {
|
||||
*MySQLUsers
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func NewStore(db *database.DB) *Store { return &Store{MySQLUsers: NewMySQLUsers(db.DB), db: db} }
|
||||
func (s *Store) WithTx(ctx context.Context, work func(UserRepository, func(string, int64, string) error) error) error {
|
||||
return s.db.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
audit := func(operation string, id int64, requestID string) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO user_audit (user_id,operation,request_id,created_at) VALUES (?,?,?,?)", id, operation, requestID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
return work(NewMySQLUsers(tx), audit)
|
||||
})
|
||||
}
|
||||
|
||||
// CheckSchema validates columns; it never mutates database schema.
|
||||
func (s *Store) CheckSchema(ctx context.Context) error {
|
||||
if err := s.MySQLUsers.CheckSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id,user_id,operation,request_id,created_at FROM user_audit LIMIT 0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.Join(rows.Err(), rows.Close())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"example.com/userService/internal/model"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
// This suite runs only against the disposable database created by verifyAPIWorkflow.
|
||||
func TestTransactions(t *testing.T) {
|
||||
if os.Getenv("USER_SERVICE_TEST_DATABASE") != "1" {
|
||||
t.Skip("isolated MySQL required")
|
||||
}
|
||||
cfg, err := database.ConfigFromEnv("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewStore(db)
|
||||
for _, kind := range []string{"error", "panic", "cancel", "commit"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
email := kind + "@transaction.example"
|
||||
var recovered any
|
||||
func() {
|
||||
defer func() { recovered = recover() }()
|
||||
err = store.WithTx(ctx, func(r UserRepository, audit func(string, int64, string) error) error {
|
||||
u, e := r.Create(ctx, model.UserInput{Name: "Transaction", Email: email})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = audit("created", u.ID, "transactionCheck"); e != nil {
|
||||
return e
|
||||
}
|
||||
switch kind {
|
||||
case "error":
|
||||
return errors.New("rollback sentinel")
|
||||
case "panic":
|
||||
panic("rollback sentinel")
|
||||
case "cancel":
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
if kind == "panic" && recovered == nil {
|
||||
t.Fatal("panic lost")
|
||||
}
|
||||
if kind == "commit" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kind != "commit" && kind != "panic" && err == nil {
|
||||
t.Fatal("failed transaction committed")
|
||||
}
|
||||
var count int
|
||||
if e := db.QueryRow("SELECT COUNT(*) FROM users WHERE email=?", email).Scan(&count); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := 0
|
||||
if kind == "commit" {
|
||||
want = 1
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("rows=%d want=%d", count, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
var audits int
|
||||
if err = db.QueryRow("SELECT COUNT(*) FROM user_audit WHERE request_id='transactionCheck'").Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Fatalf("audit transaction mismatch: %d", audits)
|
||||
}
|
||||
}
|
||||
16
examples/userService/internal/service/user/createUser.go
Normal file
16
examples/userService/internal/service/user/createUser.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) CreateUser(ctx context.Context, req *types.CreateUserReq) (*types.UserResp, error) {
|
||||
input, err := normalize(req.Name, req.Email)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
return s.mutate(ctx, "created", func(r repo.UserRepository) (model.User, error) { return r.Create(ctx, input) })
|
||||
}
|
||||
16
examples/userService/internal/service/user/deleteUser.go
Normal file
16
examples/userService/internal/service/user/deleteUser.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) DeleteUser(ctx context.Context, req *types.UserIDReq) error {
|
||||
_, err := s.mutate(ctx, "deleted", func(r repo.UserRepository) (model.User, error) {
|
||||
err := r.Delete(ctx, req.ID)
|
||||
return model.User{ID: req.ID}, err
|
||||
})
|
||||
return err
|
||||
}
|
||||
40
examples/userService/internal/service/user/exportUser.go
Normal file
40
examples/userService/internal/service/user/exportUser.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"example.com/userService/internal/types"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error) {
|
||||
u, err := s.GetUser(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
var nonce [16]byte
|
||||
if _, err = rand.Read(nonce[:]); err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
key := fmt.Sprintf("exports/users/%d/%s.txt", u.ID, hex.EncodeToString(nonce[:]))
|
||||
if _, err = s.deps.Uploads.Put(ctx, key, strings.NewReader(string(payload)), int64(len(payload)), "text/plain"); err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
url, err := s.deps.Downloads.PresignGet(ctx, key, 5*time.Minute)
|
||||
if err != nil {
|
||||
cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = s.deps.Files.Delete(cleanup, key)
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
s.deps.Logger.InfoContext(ctx, "user.exported", "userId", u.ID)
|
||||
return &types.ExportResp{DownloadURL: url.String()}, nil
|
||||
}
|
||||
14
examples/userService/internal/service/user/getUser.go
Normal file
14
examples/userService/internal/service/user/getUser.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) GetUser(ctx context.Context, req *types.UserIDReq) (*types.UserResp, error) {
|
||||
u, err := s.deps.Users.Get(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
return response(u), nil
|
||||
}
|
||||
18
examples/userService/internal/service/user/listUsers.go
Normal file
18
examples/userService/internal/service/user/listUsers.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context, req *types.ListUsersReq) (*types.UserPageResp, error) {
|
||||
page, err := s.deps.Users.List(ctx, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
result := &types.UserPageResp{Items: []types.UserResp{}, Total: page.Total, Page: page.Page, PageSize: page.PageSize}
|
||||
for _, item := range page.Items {
|
||||
result.Items = append(result.Items, *response(item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
65
examples/userService/internal/service/user/service.go
Normal file
65
examples/userService/internal/service/user/service.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"example.com/userService/internal/types"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Service struct{ deps *servicecontext.Context }
|
||||
|
||||
func New(deps *servicecontext.Context) *Service { return &Service{deps: deps} }
|
||||
func normalize(name, email string) (model.UserInput, error) {
|
||||
input := model.UserInput{Name: strings.TrimSpace(name), Email: strings.ToLower(strings.TrimSpace(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
|
||||
}
|
||||
func response(u model.User) *types.UserResp {
|
||||
return &types.UserResp{ID: u.ID, Name: u.Name, Email: u.Email, CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339Nano), UpdatedAt: u.UpdatedAt.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
func (s *Service) failure(ctx context.Context, err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, model.ErrUserNotFound):
|
||||
return apierrors.NotFound("USER_NOT_FOUND", "user not found")
|
||||
case errors.Is(err, model.ErrEmailTaken):
|
||||
return apierrors.New(409, "EMAIL_TAKEN", "email already in use")
|
||||
case errors.Is(err, model.ErrInvalidUser):
|
||||
return apierrors.BadRequest("INVALID_USER", "invalid user")
|
||||
case errors.Is(err, model.ErrInvalidPage):
|
||||
return apierrors.BadRequest("INVALID_PAGE", "invalid pagination")
|
||||
default:
|
||||
// Do not log SQL/DSN or user input; requestId correlates the failed operation.
|
||||
s.deps.Logger.ErrorContext(ctx, "user.operationFailed", "category", "persistence")
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
}
|
||||
func (s *Service) mutate(ctx context.Context, operation string, fn func(repo.UserRepository) (model.User, error)) (*types.UserResp, error) {
|
||||
var u model.User
|
||||
err := s.deps.Users.WithTx(ctx, func(r repo.UserRepository, audit func(string, int64, string) error) error {
|
||||
var err error
|
||||
u, err = fn(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(operation, u.ID, logx.RequestID(ctx))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
s.deps.Logger.InfoContext(ctx, "user."+operation, "userId", u.ID)
|
||||
return response(u), nil
|
||||
}
|
||||
16
examples/userService/internal/service/user/updateUser.go
Normal file
16
examples/userService/internal/service/user/updateUser.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) UpdateUser(ctx context.Context, req *types.UpdateUserReq) (*types.UserResp, error) {
|
||||
input, err := normalize(req.Name, req.Email)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
return s.mutate(ctx, "updated", func(r repo.UserRepository) (model.User, error) { return r.Update(ctx, req.ID, input) })
|
||||
}
|
||||
122
examples/userService/internal/servicecontext/context.go
Normal file
122
examples/userService/internal/servicecontext/context.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/userService/internal/middleware"
|
||||
userrepo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/settings"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
type Context struct {
|
||||
Users userrepo.UnitOfWork
|
||||
Logger *logx.Logger
|
||||
Health *health.Checker
|
||||
Middlewares map[string]httpx.Middleware
|
||||
DB *database.DB
|
||||
|
||||
Files storage.Store
|
||||
Uploads *storage.Uploader
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func() error
|
||||
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// Pass the settings loaded by main. The optional form supports lightweight
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context, logger *logx.Logger, configs ...settings.Config) (result *Context, err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(configs) > 1 {
|
||||
return nil, errors.New("expected at most one configuration")
|
||||
}
|
||||
cfg := settings.Default()
|
||||
if len(configs) == 1 {
|
||||
cfg = configs[0]
|
||||
}
|
||||
_ = cfg
|
||||
checker, err := health.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Context{Logger: logger, Health: checker, Middlewares: middleware.Defaults()}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = errors.Join(err, c.Close())
|
||||
}
|
||||
}()
|
||||
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsn, parseErr := mysql.ParseDSN(cfg.Database.DSN)
|
||||
if parseErr != nil || dsn.DBName == "" {
|
||||
return nil, errors.New("GO_OMNI_DB_DSN must contain a valid MySQL database name")
|
||||
}
|
||||
dsn.ParseTime = true
|
||||
dsn.Loc = time.UTC
|
||||
cfg.Database.DSN = dsn.FormatDSN()
|
||||
c.DB, err = database.Open(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = checker.Add(health.Probe{Name: "database", Timeout: cfg.Database.PingTimeout, Check: c.DB.Health}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = c.InitStorage(ctx, cfg.Storage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Users = userrepo.NewStore(c.DB)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.Database.PingTimeout)
|
||||
defer cancel()
|
||||
if err = userrepo.NewStore(c.DB).CheckSchema(checkCtx); err != nil {
|
||||
return nil, errors.New("user schema is missing or incompatible; apply migrations first")
|
||||
}
|
||||
authHook, authErr := middleware.NewAuthFromEnv()
|
||||
if authErr != nil {
|
||||
return nil, authErr
|
||||
}
|
||||
c.Middlewares["Auth"] = authHook
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Close is idempotent, including startup failure and graceful shutdown.
|
||||
func (c *Context) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.closeMu.Lock()
|
||||
defer c.closeMu.Unlock()
|
||||
c.closed = true
|
||||
c.closeOnce.Do(func() {
|
||||
if c.storageClose != nil {
|
||||
c.closeErr = errors.Join(c.closeErr, c.storageClose())
|
||||
}
|
||||
if c.DB != nil {
|
||||
c.closeErr = errors.Join(c.closeErr, c.DB.Close())
|
||||
}
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
81
examples/userService/internal/servicecontext/context_test.go
Normal file
81
examples/userService/internal/servicecontext/context_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/userService/internal/settings"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestStartupFailureAndIdempotentCleanup(t *testing.T) {
|
||||
if os.Getenv("USER_SERVICE_TEST_DATABASE") != "1" {
|
||||
t.Skip("isolated MySQL required")
|
||||
}
|
||||
t.Setenv("USER_SERVICE_API_TOKEN", "test-012345678901234567890123456789")
|
||||
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY", "test-012345678901234567890123456789")
|
||||
cfg, err := settings.Load("../../etc/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observer, err := database.Open(context.Background(), cfg.Database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer observer.Close()
|
||||
count := func() int {
|
||||
var n int
|
||||
if err := observer.QueryRow("SELECT COUNT(*) FROM information_schema.processlist WHERE USER=SUBSTRING_INDEX(CURRENT_USER(),'@',1) AND DB=DATABASE()").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
baseline := count()
|
||||
cfg.Server.Log.File = ""
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
||||
logger, err := logx.New(cfg.Server.Log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
root := t.TempDir()
|
||||
file := filepath.Join(root, "notADirectory")
|
||||
if err = os.WriteFile(file, []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Storage.Root = file
|
||||
if c, err := New(context.Background(), logger, cfg); err == nil {
|
||||
c.Close()
|
||||
t.Fatal("invalid storage root accepted")
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for count() != baseline {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("failed startup leaked MySQL connection")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cfg.Storage.Root = filepath.Join(root, "files")
|
||||
c, err := New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.DB.Ping(); err == nil {
|
||||
t.Fatal("closed application retained database")
|
||||
}
|
||||
if err = c.InitStorage(context.Background(), cfg.Storage); err == nil {
|
||||
t.Fatal("closed dependencies reopened storage")
|
||||
}
|
||||
}
|
||||
49
examples/userService/internal/servicecontext/storage.go
Normal file
49
examples/userService/internal/servicecontext/storage.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
|
||||
"example.com/userService/internal/settings"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
)
|
||||
|
||||
// InitStorage is called once, before serving requests. The composition root
|
||||
// owns provider clients; business services receive only capability interfaces.
|
||||
func (c *Context) InitStorage(ctx context.Context, cfg settings.StorageConfig) error {
|
||||
if c == nil || ctx == nil {
|
||||
return errors.New("storage initialization requires context")
|
||||
}
|
||||
c.closeMu.Lock()
|
||||
defer c.closeMu.Unlock()
|
||||
if c.closed {
|
||||
return errors.New("service dependencies are closed")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Files != nil {
|
||||
return errors.New("storage already initialized")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files, err := local.New(cfg.Root, cfg.MaxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signer, err := storage.NewUploadSigner(files, cfg.PublicURL, []byte(cfg.SigningKey), cfg.Policy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploader, err := storage.NewUploader(files, cfg.Policy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Files, c.Uploads, c.Downloads, c.UploadLinks, c.FileHTTP = files, uploader, signer, signer, signer
|
||||
|
||||
return nil
|
||||
}
|
||||
193
examples/userService/internal/settings/config.go
Normal file
193
examples/userService/internal/settings/config.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Package settings owns this project's configuration; it adds no business identity model.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
Database database.Config `json:"database"`
|
||||
Storage StorageConfig `json:"storage"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
server := config.Default()
|
||||
server.Address = "127.0.0.1:8080"
|
||||
server.EnableLogger = true
|
||||
server.SlowRequestThreshold = 500 * time.Millisecond
|
||||
server.Log.Service = "example.com/userService"
|
||||
server.Log.Format = "json"
|
||||
server.Log.File = "./var/log/server.log"
|
||||
server.Log.Rotation = logx.RotationConfig{Enabled: true, MaxSizeMB: 100, MaxBackups: 10, MaxAgeDays: 7, Compress: true}
|
||||
|
||||
return Config{Server: server, Database: database.DefaultConfig(), Storage: StorageConfig{
|
||||
Provider: "local", Root: "./var/files", PublicURL: "http://127.0.0.1:8080/storage/object",
|
||||
Secure: true, MaxBytes: 32 << 20, ContentTypes: []string{"text/plain", "image/png", "image/jpeg", "application/pdf"},
|
||||
LinkTTL: 5 * time.Minute, RequestTimeout: 30 * time.Second,
|
||||
}}
|
||||
}
|
||||
|
||||
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
||||
// Pass an empty path to use only defaults and environment.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
var err error
|
||||
if path != "" {
|
||||
cfg, err = config.DecodeFile(path, cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
// A stream explicitly selected by environment overrides a file profile.
|
||||
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" {
|
||||
cfg.Server.Log.File = ""
|
||||
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
||||
}
|
||||
|
||||
if err = cfg.Storage.applyEnv(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Database, err = database.ApplyEnv(cfg.Database, "")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
func (c Config) Validate() error {
|
||||
|
||||
if err := c.Database.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Database.Driver != "mysql" || c.Database.MaxOpenConns < 1 {
|
||||
return errors.New("database requires driver mysql and a positive maxOpenConns")
|
||||
}
|
||||
|
||||
if err := c.Server.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Server.Log.Format != "json" && c.Server.Log.Format != "text" {
|
||||
return errors.New("log.format must be json or text")
|
||||
}
|
||||
switch strings.ToLower(c.Server.Log.Level) {
|
||||
case "debug", "info", "warn", "warning", "error":
|
||||
default:
|
||||
return errors.New("invalid log.level")
|
||||
}
|
||||
return c.Storage.Validate()
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Root string `json:"root"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccountID string `json:"accountId"`
|
||||
Secure bool `json:"secure"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
ContentTypes []string `json:"contentTypes"`
|
||||
LinkTTL time.Duration `json:"linkTtl"`
|
||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||
// Secrets are environment-only and cannot be populated by config files.
|
||||
FilesToken string `json:"-"`
|
||||
SigningKey string `json:"-"`
|
||||
AccessKey string `json:"-"`
|
||||
SecretKey string `json:"-"`
|
||||
SessionToken string `json:"-"`
|
||||
}
|
||||
|
||||
func (s StorageConfig) Policy() storage.UploadPolicy {
|
||||
return storage.UploadPolicy{MaxBytes: s.MaxBytes, ContentTypes: s.ContentTypes}
|
||||
}
|
||||
func (s *StorageConfig) applyEnv() error {
|
||||
values := map[string]*string{
|
||||
"GO_OMNI_STORAGE_ROOT": &s.Root, "GO_OMNI_STORAGE_PUBLIC_URL": &s.PublicURL,
|
||||
"GO_OMNI_STORAGE_ENDPOINT": &s.Endpoint, "GO_OMNI_STORAGE_REGION": &s.Region,
|
||||
"GO_OMNI_STORAGE_BUCKET": &s.Bucket, "GO_OMNI_STORAGE_ACCOUNT_ID": &s.AccountID,
|
||||
"GO_OMNI_STORAGE_SIGNING_KEY": &s.SigningKey, "GO_OMNI_FILES_TOKEN": &s.FilesToken,
|
||||
"GO_OMNI_STORAGE_ACCESS_KEY": &s.AccessKey, "GO_OMNI_STORAGE_SECRET_KEY": &s.SecretKey,
|
||||
"GO_OMNI_STORAGE_SESSION_TOKEN": &s.SessionToken,
|
||||
}
|
||||
for name, target := range values {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
for name, target := range map[string]*time.Duration{"GO_OMNI_STORAGE_LINK_TTL": &s.LinkTTL, "GO_OMNI_STORAGE_REQUEST_TIMEOUT": &s.RequestTimeout} {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
v, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be a duration", name)
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
}
|
||||
if value := os.Getenv("GO_OMNI_STORAGE_MAX_BYTES"); value != "" {
|
||||
v, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New("GO_OMNI_STORAGE_MAX_BYTES must be an integer")
|
||||
}
|
||||
s.MaxBytes = v
|
||||
}
|
||||
if value := os.Getenv("GO_OMNI_STORAGE_SECURE"); value != "" {
|
||||
v, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return errors.New("GO_OMNI_STORAGE_SECURE must be a boolean")
|
||||
}
|
||||
s.Secure = v
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("GO_OMNI_STORAGE_CONTENT_TYPES")); value != "" {
|
||||
s.ContentTypes = strings.Split(value, ",")
|
||||
for i := range s.ContentTypes {
|
||||
s.ContentTypes[i] = strings.TrimSpace(s.ContentTypes[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s StorageConfig) Validate() error {
|
||||
if s.Provider != "local" {
|
||||
return errors.New("storage.provider must match the adapter selected at generation")
|
||||
}
|
||||
if err := s.Policy().Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.LinkTTL < time.Second || s.LinkTTL > time.Hour {
|
||||
return errors.New("storage.linkTtl must be 1s..1h")
|
||||
}
|
||||
if s.RequestTimeout <= 0 || s.RequestTimeout > 5*time.Minute {
|
||||
return errors.New("storage.requestTimeout must be >0 and <=5m")
|
||||
}
|
||||
|
||||
if s.Root == "" {
|
||||
return errors.New("storage.root is required")
|
||||
}
|
||||
u, err := url.Parse(s.PublicURL)
|
||||
if err != nil || u.Host == "" || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/storage/object" {
|
||||
return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")
|
||||
}
|
||||
if len(s.SigningKey) < 32 {
|
||||
return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
35
examples/userService/internal/types/typesGen.go
Normal file
35
examples/userService/internal/types/typesGen.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package types
|
||||
|
||||
type UserResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
type CreateUserReq struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
type UserIDReq struct {
|
||||
ID int64 `path:"id,range=[1:9007199254740991]"`
|
||||
}
|
||||
type UpdateUserReq struct {
|
||||
ID int64 `path:"id,range=[1:9007199254740991]"`
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
type ListUsersReq struct {
|
||||
Page int `form:"page,default=1,range=[1:1000000]"`
|
||||
PageSize int `form:"pageSize,default=20,range=[1:100]"`
|
||||
}
|
||||
type UserPageResp struct {
|
||||
Items []UserResp `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
type ExportResp struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
}
|
||||
2
examples/userService/migrations/000001_users.down.sql
Normal file
2
examples/userService/migrations/000001_users.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE user_audit;
|
||||
DROP TABLE users;
|
||||
18
examples/userService/migrations/000001_users.up.sql
Normal file
18
examples/userService/migrations/000001_users.up.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
|
||||
CREATE TABLE user_audit (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
operation VARCHAR(16) NOT NULL,
|
||||
request_id VARCHAR(128) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
INDEX ix_user_audit_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
Reference in New Issue
Block a user