Compare commits

..

10 Commits

166 changed files with 8154 additions and 1969 deletions

View File

@@ -11,7 +11,7 @@
| `openapi` | 路由与 OpenAPI 3.0.3 同时注册、DTO schema、响应信封 | 显式声明请求/响应;不自动推断任意 Handler 行为,不附带 Swagger UI |
| `auth` | RS256/EdDSA JWT 验证与公钥轮换、可撤销 Session、Policy 接口 | 登录验证和业务授权规则由接入方提供;不自动获取 token 指定的密钥 |
| `telemetry` | HTTP 入站/出站、Redis、SQL spans,HTTP 指标和 traceId 日志关联 | 注入 OTel exporter/reader 后才外发;SQL Query span 不覆盖后续遍历;不接管全局 provider |
| `config` / `logx` | JSON/YAML 文件、环境变量覆盖、动态日志级别、轮转/脱敏/堆栈/慢请求 | 热更新只开放日志级别;连接池、监听、认证等静态变更要求重启 |
| `config` / `logx` | JSON/YAML/XML 文件、显式环境引用、动态日志级别、轮转/脱敏/堆栈/慢请求 | 热更新只开放日志级别;连接池、监听、认证等静态变更要求重启 |
| `resilience` | 失败阈值、熔断冷却、单探针恢复、上游并发隔离 | 一个实例对应一个依赖;默认内存状态,不做跨进程全局熔断 |
| `middleware/redis` | 跨副本限流、幂等 claim/回放、容量限制 | 专用 Redis namespace;TTL 窗口内保护不等于永久业务去重 |
| `queue` / `queue/redis` | 持久消息接口、Streams 接管、有限重试、失败消息管理 | 至少一次交付,处理器必须幂等;持久性取决于 Redis 部署 |
@@ -102,7 +102,7 @@ err = consumer.Run(serviceContext, func(ctx context.Context, item queue.Delivery
## 配置与日志热更新
`config.LoadFile(path, prefix)` 按默认值→文件→非空环境变量加载并验证。两种文件格式都使用 JSON tag 的 lowerCamelCase,duration 可以写 `"5s"`;文件上限 1 MiB,拒绝未知字段、多文档和非法值。自定义 prefix 要包含期望的尾部下划线,例如 `BILLING_`。空环境变量沿用文件值,不清空已配置字段。
`config.LoadFile(path)` 使用默认值→文件字段显式引用→类型绑定→校验;没有自动环境覆盖。用 `${NAME}` 或 `${NAME:-fallback}` 指定来源,完整规则与迁移见 [CONFIG.md](CONFIG.md)。
```yaml
address: '127.0.0.1:8080'
@@ -113,7 +113,7 @@ log:
format: json
```
先按配置创建 `logx.New(cfg.Log)`,再 `config.NewReloader(path, prefix, logger)`。显式调用 `Reload()` 或运行 `Watch(ctx, time.Second, onError)`;后者阻塞,生命周期由应用负责。只有 `log.level` 允许变化,校验失败或静态字段变化保留原快照与日志级别。子 logger 共享动态级别。替换文件时建议使用临时文件 + rename。
先按配置创建 `logx.New(cfg.Log)`,再 `config.NewReloader(path, logger)`。显式调用 `Reload()` 或运行 `Watch(ctx, time.Second, onError)`;后者阻塞,生命周期由应用负责。只有 `log.level` 允许变化,校验失败或静态字段变化保留原快照与日志级别。子 logger 共享动态级别。替换文件时建议使用临时文件 + rename。
## 熔断与观测

View File

@@ -6,3 +6,5 @@
- examples 使用通用场景,避免依赖业务项目、私有数据库或线上服务。必要的外部基础设施配置应明确说明。
- 2026-09-25 用户决定:移除 mappings/mappingFiles 及结构映射代码生成机制,改用可选 copyx 工具包。不得恢复另一套 JSON 或 Go 映射声明语言。
- 深拷贝的自定义转换结果不得与输入共享可变引用;发现共享应报错,不能降级成浅拷贝或静默接受。
- 2026-09-26 用户决定:配置文件显式声明固定值或 `${NAME}` / `${NAME:-fallback}`,旧自动环境覆盖、前缀/别名及 env/envDefault 标签模式移除,不得重新引入隐式二次覆盖。项目维护 Config/Default/Validate/文件,生成器维护 Load/main。

4
API.md
View File

@@ -48,7 +48,7 @@ go run ./cmd/go-omni new --api examples/contracts/user.api \
--log-output file --log-format json ../userService
```
`api generate` 在新目录中也会生成完整项目。默认只监听 localhost;`GO_OMNI_HTTP_ADDR` 修改地址,`GO_OMNI_TRANSPORT=native` 切换网络层。配置顺序为默认值 → `etc/config.yaml` → 环境变量。文件日志支持现有轮转配置。远程试用使用与 CLI 一致的固定版本,见 [发布说明](RELEASE.md)。
`api generate` 在新目录中也会生成完整项目。默认只监听 localhost;`GO_OMNI_HTTP_ADDR` 修改地址,`GO_OMNI_TRANSPORT=native` 切换网络层。配置顺序为 Default() → `etc/config.yaml` 中的显式环境引用 → 类型绑定 → Validate();固定值不再被隐式覆盖。文件日志支持现有轮转配置。远程试用使用与 CLI 一致的固定版本,见 [发布说明](RELEASE.md)。
API 项目现在支持组合数据库、存储和日志:
@@ -63,7 +63,7 @@ go-omni new --api user.api --module example.com/userService \
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。new 与 api new 统一生成契约项目,不再使用旧式 CRUD 目录。
MySQL 使用官方 `database/mysql` 结构化适配器,密码从 `GO_OMNI_DB_PASSWORD` 覆盖,非秘密池配置位于 YAML,环境变量优先。存储使用原有 `GO_OMNI_STORAGE_*`;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
MySQL 使用官方 `database/mysql` 结构化适配器,生成文件以 `password: "${GO_OMNI_DB_PASSWORD}"` 显式读取密码;其他字段可写固定值或引用,缺失项保留 Default()。存储配置也只读取文件明确引用的变量;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。当前目录的完整示例见 [managedService](examples/managedService/README.md)。MySQL、事务审计和鉴权实现可参考 [旧目录用户管理示例](examples/userService/README.md),接入时按 [项目指南](PROJECT.md) 装配到 config/svc/logic。

View File

@@ -5,7 +5,7 @@
## 实施与验收清单
- [x] 运行期:资源登记、逆序释放、幂等关闭、失败回滚、故障原因保留归框架;生成入口与项目依赖初始化边界明确。多 App 日志隔离、标准 log.Logger 适配、可信请求元数据与依赖中间件示例。
- [x] 配置:项目完整结构体环境覆盖;默认值→文件→环境;嵌套、duration、slice、显式零值、空值策略、必填与脱敏;日志无副作用完整校验。
- [x] 配置:Default()→文件显式环境引用→类型绑定→Validate;嵌套、duration、slice、显式零值、必填/备用值、转义与安全诊断;日志无副作用完整校验。
- [x] 数据库:官方 MySQL 结构化配置/TLS/驱动日志适配,继续复用 OpenConnector 和唯一连接池;独立启动检查、失败清理、生成器接入;不内置版本/表结构/业务错误策略。
- [x] 响应契约:数组、可空对象、成功 null、独立 HTTP 204;Logic/Handler/OpenAPI/漂移检查一致。
- [x] 请求契约:未知 query、JSON UTF-8/charset、无 Body/必填空对象/可选 Body,App 默认与路由覆盖;scalar/any/数组/大小限制一致,普通 JSON 只绑定一次。

View File

@@ -1,5 +1,45 @@
# Changelog
## v0.25.0-preview(2026-09-26)
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
- 回调失败后只有显式回滚返回成功才标记 rolledBack;提交报错一律保守返回 unknown,不根据断网/超时或 ErrTxDone 推断回滚。不自动重试事务,不处理文件清理等业务决策。
- Err/RollbackErr 分开保留,errors.As/Is 可遍历两条原因链;业务错误目录和公开响应行为保持。nil context 接受,nil 回调在开启前报错,panic/Goexit 保持传播并尝试回滚。
- 新增阶段/取消竞争/确认丢失/并发/公开响应回归、真实 MySQL 驱动边界故障验收、性能比较和 examples/transactions;同步 README 与 database 指南。
- 错误现在有类型包装;直接错误相等、直接类型断言和错误文本依赖需改为 errors.Is/As。标准库异步回滚不公开的底层原因无法还原,结果保持 unknown;不改变 GORM 自己的 Transaction。
## v0.24.0-preview(2026-09-26)
- 新增可选 cors 包和 server.cors 配置;生成项目直接加载,手写项目使用 omni.WithCORS,独立 HTTP 服务可使用 Policy.Wrap。默认关闭,无新增第三方依赖。
- 精确来源白名单、方法/请求头、暴露头、凭据和预检缓存可配置;拒绝凭据与通配来源混用、非法来源/头及歧义的重复请求头,校验错误定位具体字段。
- 在路由和鉴权之前处理预检;实际请求仍执行原有中间件,统一响应、404/405、上传下载和流式能力保持。nethttp/native/HTTP2 共用策略。
- 补充真实生成项目、TLS 三种 transport、20 项真实浏览器检查、race/fuzz/性能基准;同步 README、配置指南、cors 指南及 examples/cors。
- 没有移除现有功能或修改默认跨域行为。代理已负责 CORS 的项目保持关闭,避免重复头;高级调用 Router().ServeHTTP 不经过应用外层 CORS。
## v0.23.0-preview(2026-09-26)
- MySQL/连接池、日志、HTTP 客户端、生命周期和请求策略返回字段级安全诊断,配置加载器保留文件/变量/位置与原始错误链。
- httpc.JSON 新增 WithSuccessStatuses,单次调用可限制允许的 2xx,默认兼容,拒绝响应继续关闭和有界回收。
- copyx.Check[S,T] / Copier.Check 预检嵌套类型与现有规则;修正空集合隐藏类型不匹配,动态接口和 converter 的具体行为继续运行时验证。
- logx.Config.TimeZone 绑定 Logger 实例,JSON/text/子 Logger/标准日志适配一致;不修改 time.Local 或业务时间属性。
- 无新映射文件、强制模块或第三方依赖;更新 README、各包指南和 developerTools 可运行示例。错误文本更精确,空集合中的确定类型错误现在会被拒绝。
## v0.22.0-preview(2026-09-26)
- 文件显式环境引用与备用值,字符串组合/转义,安全有界展开和类型绑定;变量值原样插入、不递归处理。
- 移除隐式环境覆盖、EnvOptions/前缀/别名及 env/envDefault;数据库统一使用文件加载,不再单独扫描环境。
- 生成 main/Load 新增 -check-config,精简配置文件,保留开发者扩展;错误字段建议和迁移检查。
- README、CONFIG、数据库/HTTP 客户端指南及示例同步新规则;配置中的响应模板需要 $$ 转义,.api 不变。
## v0.21.0-preview(2026-09-26)
- httpc 可配置环境/直连/固定代理、常用连接阶段超时和独立空闲池参数。
- JSON 新增逐请求 Header 快照与响应大小选项,保持有界读取、关闭和认证隔离。
- 成功请求少复制和退避计算,丢弃响应流式处理,错误体有界回收以改善连接复用。
- 增加真实本地代理、超时、连接限制/回收、并发、日志脱敏、gzip、fuzz 和修改前后基准。
- JSON 自定义方法接口签名需同步 variadic 参数;借用 Transport 的参数混配现在报错。详见 httpc/README.md。
## v0.20.0-preview(2026-09-26)
- 生成项目配置加载入口持续由生成器维护;保留业务字段、默认值、校验与 NewLoader 扩展文件。

277
CONFIG.md
View File

@@ -1,178 +1,195 @@
# 统一配置加载
# 配置:文件明确声明值的来源
v0.20 起,新生成服务的 `main.go` 调用生成器维护的 `config.Load`。项目只需要定义业务字段、默认值和校验;日志、HTTP、数据库等配置使用同一流程,配置加载时不打开数据库、监听端口或创建日志文件。
v0.22 起只使用这一条流程:
```text
代码默认值 → envDefault(仅填零值)→ 可选配置文件
→ 完整结构体环境覆盖(含别名)→ 框架规范化 → 项目 Validate
→ 初始化日志及项目依赖 → 启动服务
项目 Default() → 解析选中的一个文件 → 解析字段值中的环境引用
→ 按 Go 类型绑定已提供的字段 → 项目 Validate() → 初始化日志和依赖
```
## 生成项目要维护什么
没有环境前缀、别名表或隐式二次覆盖。业务只维护 `Config`、`Default()`、`Validate()` 和配置文件。项目 Validate 中继续调用嵌套框架配置的 Validate;生成模板已包含这些调用。验证函数只检查值,不应打开资源。
| 文件 | 维护方 | 用途 |
| --- | --- | --- |
| `main.go` | 生成器 | 启动、信号、请求排空、依赖关闭 |
| `internal/config/loadGen.go` | 生成器 | 调用统一加载入口;纳入 `generate --check` |
| `internal/config/config.go` | 项目 | `Config`、`Default()`、`Validate()` |
| `internal/config/loader.go` | 项目 | `NewLoader()`,只有增加文件格式时才需修改 |
| `etc/config.yaml` | 项目 | 部署配置;可改用 JSON/XML 并传 `-config` |
| `internal/svc` | 项目与生成器分文件 | 初始化依赖、登记清理、AppOptions |
比如增加业务的批次数量:在 Config 增加一个字段,在 Validate 加范围校验;需要部署值时改配置文件或环境变量。不修改 main、Load 或 Handler。
## 直接使用
```go
// 添加到项目 Config。
BatchSize int `json:"batchSize" envDefault:"100"`
// 添加到项目 Validate。
if c.BatchSize < 1 || c.BatchSize > 1000 {
return omniConfig.Invalid("batchSize", "must be between 1 and 1000")
}
```
自动支持 `GO_OMNI_BATCH_SIZE`。生成服务的空环境值策略为 `ignore`,0 和 false 是有效覆盖;范围由 Validate 决定。
重新生成保留 config.go、loader.go 和 svc 的手写扩展。
## 手写项目用法
```go
package main
import (
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
)
type Config struct {
Timeout time.Duration `json:"timeout" envDefault:"3s"`
Limit int `json:"limit" envDefault:"100"`
Token string `json:"-" env:"APP_TOKEN,secret"`
Server omniConfig.Config `json:"server"`
BatchSize int `json:"batchSize"`
Enabled bool `json:"enabled"`
Password string `json:"password"`
Timeout time.Duration `json:"timeout"`
}
func Default() Config {
return Config{Server: omniConfig.Default(), BatchSize: 100, Enabled: true,
Timeout: 3*time.Second}
}
func (c Config) Validate() error {
if c.Timeout <= 0 {
return config.Invalid("timeout", "must be positive")
if err := c.Server.Validate(); err != nil {
return omniConfig.AtField("server", err)
}
if c.BatchSize < 0 { return omniConfig.Invalid("batchSize", "must not be negative") }
return nil
}
// cfg, err := config.LoadProject("etc/config.yaml", Config{},
// config.EnvOptions{Prefix: "APP_", Empty: "reject"})
```
`LoadProject` 接收结构体默认值,可实现值接收器或指针接收器的 `Validate() error`。空路径表示不读文件。
`DecodeFile` 只做文件覆盖,不做 envDefault、环境覆盖或业务 Validate;旧的两参数调用仍有效,可用结构体或结构体指针。低层 `ApplyDefaults`、`OverlayEnv`、`LoadFile` 继续存在。
嵌套校验用 `config.AtField("client", err)` 加路径前缀。公共原因通过 `config.Invalid` 提供,原因中不要包含密码、DSN、Token 或输入原值。
## JSON / YAML / XML
按文件扩展名选择解析器:`.json`、`.yaml`、`.yml`、`.xml`,大小写不敏感。所有格式使用 Go 结构体的 **json 字段名**,无需为 XML 复制一份结构或标签。
以下配置等价:
```json
{"client":{"timeout":"2s"},"enabled":false,"ports":[8080,8081],"optional":null}
// 手写项目;生成项目的 Load 已自动调用此入口。
cfg, err := omniConfig.LoadProject("etc/config.yaml", Default())
```
```yaml
client:
timeout: 2s
server:
address: "${HTTP_ADDR:-127.0.0.1:8080}"
password: "${DB_PASSWORD}"
timeout: "${REQUEST_TIMEOUT:-3s}"
enabled: false
ports: [8080, 8081]
optional: null
```
文件未写 batchSize,保留 100;enabled 明确写 false,就使用 false。变量名字完全由文件决定,不需要对应 Go 字段名。配置文件可只列出部署需要变动的字段;不会自动导出/打印包含凭据的完整配置。
## 引用语法
| 文件里的值 | 含义 |
| --- | --- |
| `host: 127.0.0.1` | 固定值,不读取任何环境变量 |
| `password: "${DB_PASSWORD}"` | 未设置或空字符串报错,不回退到 Default() |
| `port: "${DB_PORT:-3306}"` | 未设置或为空时使用 3306 |
| `sessionToken: "${SESSION_TOKEN:-}"` | 未设置或为空时使用空字符串,是否合法由目标类型和 Validate 决定 |
| `url: "https://${HOST}:${PORT:-443}/v1"` | 普通字符串中可出现多个引用 |
| `literal: "$${DB_PASSWORD}"` | 得到字面量 `${DB_PASSWORD}`,不读取变量 |
| `literal: "$$cost"` | `$$` 转义一个 `$`,得到 `$cost` |
变量名遵循 `[A-Za-z_][A-Za-z0-9_]*`。只支持 `${NAME}`、`${NAME:-fallback}` 和 `$$` 转义,不支持 `${NAME-default}`、嵌套引用或 shell 表达式。普通单独的 `$` 保留。fallback 到第一个 `}` 结束,可包含冒号、`:-`、引号等文件格式允许的字符;需要包含 `}` 的值可使用固定值或环境变量。
解析只扫描文件的**已解码字段值**,不替换 YAML/JSON/XML 源文本,不扩展字段名,也不扫描 Default() 的字符串。插入的环境值和 fallback 不再展开。密码 `DDSFLKJlsf:saf:-123`、引号、换行、`${OTHER}` 均原样保留,不破坏文档结构。字符串不统一 TrimSpace;`"0"`、`"false"` 和全空格字符串均属于已提供。
同一变量每次加载最多读取一次;下一次加载会重新读取。没有全局变量值缓存。并发加载各自维护结果,不能把调用者正在修改的默认配置当作共享可变输入。
## 类型与覆盖
目标 Go 类型决定引用结果的转换:整数、浮点数、布尔值、`time.Duration`、字符串、指针及已有自定义 JSON/Text 反序列化。数值范围按目标位宽检查,整数不经过 float64。自定义反序列化仍由自定义类型解释文本,不猜测其业务含义。
- 文件缺失字段保留初始值;显式 0、false、空字符串正常覆盖,不用零值判断字段是否存在。
- `${MISSING}` 失败就报错;需要备用值必须写 `${MISSING:-value}`。
- 字符串编号 `"001"` 保持 `001`;绑定到 int 才转整数。环境里的 `null` 不自动变成空指针,`[1,2]` 不自动变成数组。
- 集合直接在文件里声明,可在每个标量元素中使用引用,例如 `ports: ["${PORT_A:-8080}", "${PORT_B:-8081}"]`。
- 数组整体替换;空 slice 保持非 nil;固定长度 Go 数组要求长度一致。map 合并键,已提供键的值整体替换。
- 固定文件 null 沿用 JSON 语义:清空指针/map/slice/interface,普通值字段保持初始值。需要区分 null 时使用指针。
- `any` 的引用结果保持字符串,不猜测动态类型。固定 JSON/YAML 数字使用 json.Number 保留精度。
- 引用成功后的值不会再覆盖一次。旧 `GO_OMNI_*` 名称只有出现在文件引用中才有作用;不隐式重置日志 file/rotation。
普通固定字符串仍按原有严格绑定规则处理;不会为了支持变量,把所有 `"123"` 都自动转换成数字。
## YAML、JSON、XML 一致
使用 Go 的 `json` 字段名,无需维护三份 tag。以下三份文件等价:
```yaml
port: "${PORT:-3306}"
password: "${PASSWORD}"
```
```json
{"port":"${PORT:-3306}","password":"${PASSWORD}"}
```
```xml
<config>
<client><timeout>2s</timeout></client>
<enabled>false</enabled>
<ports type="array"><item>8080</item><item>8081</item></ports>
<optional type="null"/>
</config>
<config><port>${PORT:-3306}</port><password>${PASSWORD}</password></config>
```
XML 必须使用一个 `<config>` 根;普通叶节点按目标 Go 类型转换。数组显式 `type="array"` 加 `<item>`,不根据重复元素猜测类型。`type="array"` 空元素表示空数组,`type="object"` 表示空对象,`type="null"` 表示 null。`any` 中的 XML 叶节点默认是字符串,需要动态数字/布尔时标注 `type="number"` / `type="bool"`;也可用 `type="string"`。
XML 使用 `<config>` 根,标量占位符写在普通叶元素或 `type="string"` 元素中,由 Go 字段决定类型;显式 `type="number"` / `type="bool"` 表示文件里已经有合法数字/布尔常量。XML 数组用 `type="array"` 加 `<item>`;空对象 `type="object"`、null 用 `type="null"`。不从重复元素猜测数组。
类型和覆盖规则:
`.api` 不经过配置加载器,响应模板 `${code}` 不变。**如果把响应模板写在 YAML/JSON/XML 配置文件里,须转义:**
- 缺失字段保留默认值;显式 0、false、空字符串正常覆盖。
- null 清空指针、map、slice、interface;普通值字段保持默认值,沿用 Go JSON 解码语义。需要区分 null 时用指针。
- 数组整体替换;空 slice 保持非 nil;固定长度 Go 数组要求长度一致,避免丢弃多余配置。
- map 合并键,已提供的键整体覆盖对应值;map 中未提供的键保留。
- duration 支持 `250ms`、`3s`;整数保持精度,动态 JSON 数字使用 `json.Number`,不先转 float64。
- 自定义 `json.Unmarshaler` / `encoding.TextUnmarshaler` 仍可接入。这些回调是可信项目代码,必须自己保证并发和副作用安全;不要修改共享外部状态。
- JSON `,string` 字段、嵌入字段遵循 JSON 字段选择;未知字段、重复绑定、冲突均拒绝。
内置解析器限制:单文件最多 1 MiB,解析最多 64 层、100000 个节点;绑定和默认值复制也有深度限制。只接受普通文件,不读取设备或命名管道。不支持多文档、重复字段、无效 UTF-8;YAML 拒绝 alias/merge/custom tag,XML 拒绝 DTD/外部实体、命名空间、业务属性和混合内容。XML 仅支持配置所需的子集,不是通用 XML 文档映射器。
这些限制针对启动配置,避免意外超大文件、别名扩展和模糊覆盖。自定义值解码器的内部字段校验由该解码器负责;例如现有错误目录使用其专有解码规则。
## 环境覆盖
统一入口的 EnvOptions 控制前缀、空值策略、别名和 Lookup。完整 JSON 路径自动转换为 UPPER_SNAKE_CASE:`client.timeout` → `APP_CLIENT_TIMEOUT`。
```go
config.EnvOptions{
Prefix: "GO_OMNI_", Empty: "ignore",
Aliases: config.ServerEnvAliases("GO_OMNI_", "server"),
}
```yaml
server:
response:
body:
code: "$${code}"
msg: "$${message}"
data: "$${data}"
```
生成项目同时接受完整的 `GO_OMNI_SERVER_HTTP_READ_TIMEOUT` 与短别名 `GO_OMNI_HTTP_READ_TIMEOUT`。完整名存在时优先,即便其空值被 ignore,也不回退别名。不会再二次调用 ApplyEnv 覆盖业务字段。
加载后恢复为 `${code}` 等响应占位符,仍由响应编码器处理。
显式 `env:"CUSTOM_NAME"` 是绝对名称;`env:"-"` 跳过子树。`json:"-"` 只在声明显式 env 名时参与,可让密钥只能从环境加载。`secret` 标签表达用途,解析错误对所有字段都不打印值。slice/map 的环境值采用 JSON。`required` 检查字符串非空、指针/集合非 nil;复杂范围由 Validate 负责。详见 [完整业务环境覆盖](DEVELOPMENT.md#完整业务配置的环境覆盖)。
## 生成项目与检查
显式环境选择日志 stdout/stderr 会清除文件 profile 的 File/Rotation,除非同时提供非空 File 覆盖。该兼容规则由框架处理。
| 文件 | 维护方 |
| --- | --- |
| `main.go`、`internal/config/loadGen.go` | 生成器维护,纳入 `generate --check` |
| `internal/config/config.go` | 项目维护 Config、Default、Validate |
| `etc/config.yaml` 等选定文件 | 项目维护,重新生成不覆盖 |
| `internal/config/loader.go` | 初次生成即可使用,仅添加自定义格式时才修改 |
## 扩展解析器
```sh
go run . -config etc/config.test.yaml -check-config
go run . -config etc/config.local.yaml
go build .
go-omni api generate --check
```
内置格式直接可用。新增格式只在 `internal/config/loader.go` 的 `NewLoader` 注册:
`-check-config` 使用与启动完全相同的 Load,通过时退出 0、失败时非零;不打印配置,不创建日志文件、数据库、后台组件或 HTTP 监听。它只校验配置,不检测真实数据库连接或证书握手。
`-config` 只选一个文件,不自动叠加 local/test/default。指定文件不存在时报错;`-config ''` 只使用 Default() 后校验,不读取任何环境变量,也不寻找其他文件。示例:[examples/configuration](examples/configuration/README.md)。
## 安全诊断与限制
```text
config "etc/config.yaml":7:9 env "DB_PORT" field "database.port": expected a valid int
```
`*config.Error` 包含 File/Field/Env/Line/Column/Reason。引用错误同时保留文件与环境变量名;组合引用失败可列出多个名字。行列定位原文件字段,不把环境值伪装成源文件文本。无法取得位置时仍提供可用的文件/字段信息。
用 `config.Invalid(field, publicReason)` 或 `config.AtField(prefix, err)` 报业务校验错误。未知字段给出唯一且接近的已声明字段建议,不自动修改。错误默认不打印原值;自定义 Reason 不应拼入秘密值。原始 cause 仍支持 errors.Is/As,不要把 Unwrap 后的错误或整个配置写到日志。
文件最多 1 MiB;单个环境值/展开字段最多 1 MiB,展开字符串合计/变量缓存分别最多 4 MiB;解析/展开限制 64 层、100000 个节点与引用计数。拒绝无效 UTF-8、多文档、重复字段、未知字段、非普通文件;YAML 不接受 alias/merge/custom tag,XML 不接受 DTD/外部实体。不执行 shell,不自动加载 `.env`。
## 框架模块的字段诊断
v0.23 起 MySQL(含连接池/TLS)、通用数据库池、日志(含轮转/时区)、HTTP 客户端、退出阶段和请求策略返回字段级安全原因。沿用项目已有的 `config.AtField("database", err)` / `config.AtField("server", err)`,自动组合嵌套字段,不需业务重复校验基础参数。
例如文件中 `database.port: "${DB_PORT}"` 的引用值为 70000:
```text
config "etc/config.yaml":3:9 env "DB_PORT" field "database.port": must be between 1 and 65535
```
日志级别非法指向 `server.log.level`;池参数指向 `database.pool.maxIdleConns` / `pingTimeout`。框架只公开已定义的安全原因,第三方/项目普通 error 仍显示 validation failed 并保留 errors.Is/As 原因链;业务使用 Invalid/AtField 提供公开原因。不要解析错误字符串;使用 `*config.Error` 字段。未提供字段的位置可能不可用,字段与原因仍保留。
生成项目现有 Load 和 `-check-config` 自动获得这些诊断。日志可在 `server.log.timeZone` 配置 IANA 名称,见 [实例时区](logx/README.md#实例时区)。
## 可选解析器与测试来源
内置格式无需项目手写加载器。需要额外格式时在已有 NewLoader 注册 Decoder,后续仍自动经过同一引用/绑定/校验流程:
```go
func NewLoader() (*omniConfig.Loader, error) {
return omniConfig.NewLoader(
omniConfig.WithDecoder("custom", omniConfig.DecoderFunc(
func(data []byte) (omniConfig.Document, error) {
// 演示注册扩展名;实际可在这里调用项目选定的 TOML 等解析库。
return (omniConfig.JSONDecoder{}).Decode(data)
},
)),
omniConfig.WithDecoder("custom", omniConfig.JSONDecoder{}),
)
}
```
Decoder 返回 Document:Value 是 map[string]any 根,子值为 string/bool/json.Number/nil/[]any/map[string]any;无自身类型的叶节点可返回 config.Text。Positions 可选,用 `client.timeout` 或 `servers[0].timeout` 标记 1-based 行列。不提供位置时仍有文件、字段、原因。
Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量可用 Text),可提供 Positions。实例注册无全局 init;插件必须可并发调用、不修改共享输入,并且是可信 Go 代码。
自己的语法错误可返回 `&config.Error{Field: "client.timeout", Line: 3, Column: 5, Reason: "expected a duration"}`。Reason 必须安全;普通第三方错误仅公开 `invalid configuration syntax`,内部 cause 通过 errors.Is/As 保留。
测试或嵌入宿主可以用 `WithLookupEnv(func(string)(string,bool))` 替换引用查找函数。它仍然只读取文件明确引用的名称,不启用任何自动覆盖。普通业务不需要这个选项。
注册表属于 Loader 实例,无全局 init 注册;不能覆盖已有扩展名。Loader 可共享,但 Decoder 必须支持并发且不保留/修改输入。解析插件是可信 Go 代码,框架不是插件沙箱。新格式依赖由选择它的项目显式引入,核心不强制引入 TOML 等库。
## v0.22 迁移:旧模式已移除
## 定位配置错误
这是不兼容调整,不保留两套加载逻辑:
```text
config "etc/config.yaml":4:12 field "client.timeout": expected a duration such as 5s
config env "APP_CLIENT_TIMEOUT" field "client.timeout": expected a valid time.Duration
```
- 删除 `EnvOptions`、`LoadFromEnv`、`ApplyEnv`、`OverlayEnv`、`ApplyDefaults`、`ServerEnvAliases` 和 `env`/`envDefault` 标签处理;检出遗留标签会提示迁移。
- 删除 `database.ConfigFromEnv` / `database.ApplyEnv`;数据库配置作为项目 Config 的字段绑定。DSN 字段可在文件里使用 `dsn: "${DB_DSN}"`,序列化 database.Config 不输出 DSN 原值。
- `LoadProject(path, defaults[, loader])` 不再接收 EnvOptions;`LoadFile(path)`、`NewReloader(path, logger)` 不再接收前缀。
- `omni.LoadConfig(path)` 的参数现在是文件路径;空路径只用框架默认值。
`errors.As(err, &diagnostic)` 可获取 `*config.Error` 的 File/Field/Env/Line/Column/Reason;原 `DecodeError` 名称保留为别名。`errors.Is` 可判断文件不存在等 I/O 原因。
迁移顺序:
解析失败显示文件和可获取的行列;Validate 使用 Invalid/AtField 才能定位业务字段。环境覆盖导致的错误优先标环境变量,不错误地归因到文件;数组元素验证也会追溯到覆盖它的集合环境变量。默认值未出现在文件里时没有行列。YAML 语法错误通常只有行号,列显示 1。
1. 将 envDefault 默认值搬入 Default(),移除 env/envDefault 标签。需要文件绑定的旧 `json:"-"` 密钥字段改成明确 json 名称;不打印配置对象。
2. 按实际部署需要,在选定文件写环境引用。固定值保持固定,不按原前缀猜测。原先依赖环境切换日志输出的项目,应在文件明确配置 file/output/rotation。
3. 配置文件中的其他 `${...}` 模板改成 `$${...}` 字面量;`.api` 无需修改。
4. 手写项目改为 LoadProject。生成项目保留 Config/Default/Validate/NewLoader,删除旧手写 Load 后重新生成;main/loadGen 由生成器更新,业务配置文件不会被静默改写。
5. 升级匹配的 CLI 与框架,执行 generate、generate --check、go test、go build 和 `-check-config`,再使用真实部署配置启动。
不打印底层错误原文或配置值;任意普通 `errors.New(...)` 校验失败的公开原因是 `validation failed`,内部原因仍在链中。项目应改用 Invalid 给出安全的原因。第三方子配置校验若没有字段元数据,只定位到对应子树,框架不会从任意错误文案猜字段。
低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。
不要把 Unwrap 后的原始错误、整个配置对象写入公开日志/HTTP;自定义解析器与校验器传入的 Reason 由项目负责脱敏。
## 跨域配置
## v0.19 及更早项目迁移
先提交现有代码,再升级匹配的 CLI 与框架。旧项目的 `internal/config/config.go` 含手写 Load,新生成器会明确阻止 generate/check/dry-run,不会直接覆盖它。
1. 保留 Config、Default、Validate、业务配置结构及辅助函数。
2. 删除旧 Load。普通的 defaults/file/env/Validate 流程已由 LoadProject 接管;移除由此不再使用的 import。将自定义默认值和校验分别迁入 Default / Validate,自定义文件格式迁入 NewLoader。
3. 框架只在 `loader.go` 不存在时创建 NewLoader;已有项目文件同名时需要保证它提供同签名函数。无需手写 loadGen.go。
4. 重新生成,执行 `generate --check`、`go test ./...`、`go build .`,再用真实配置验证启动。
保留业务需要的显式 env 标签/规则;如果原 Load 有解密或其他项目特殊步骤,不要直接删除,应先明确其归属,必要时在自定义 Decoder 中处理。生成器不自动搬动或猜测手写行为。`api upgrade` 仍可预览较早版本的基础设施差异,但已修改的配置必须按上述步骤迁移。
不会移除低层配置 API;本次移除的是**新项目里重复手写的加载流程**,不删除旧项目的业务文件。
完整可运行例子:[examples/configuration](examples/configuration/README.md)。
生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。

25
COPY.md
View File

@@ -28,6 +28,31 @@ if err := copyx.Copy(&input, req); err != nil { return err }
新字段:普通接口只改 `.api` 和使用该字段的 Logic。独立 DTO/业务类型都需要此字段时,两边各声明一次;同名同类型不增加拷贝配置。检查在运行时发生,实际类型对必须有测试,不能把运行时工具当作旧生成器的编译期字段覆盖检查。
## 类型预检
v0.23 新增可选预检,无需填写映射文件或构造测试值:
```go
// 参数方向是源 → 目标;与 Copy 使用同一组选项。
if err := copyx.Check[Request, BusinessInput](options...); err != nil {
return err
}
// 复用已有 Copier:传结果类型,不添加 Copy 所需的额外输出指针。
err := copier.Check(reflect.TypeFor[Request](), reflect.TypeFor[BusinessInput]())
```
适合 CI 测试或初始化检查;这是程序执行的类型检查,不是 Go 编译器自动证明。递归检查切片/数组元素、map 键值、指针、嵌套字段、改名、忽略和严格覆盖;用已访问类型对处理递归结构。不执行 converter,也不调用构造器,不读取实例值。错误路径如 `$.Rows[].Value: copyx: incompatible types: string -> int`。
Check 复用 Copy 的规则,包括浅拷贝对同类型容器的直接赋值:这种情况下内部 converter 不会自动逐项执行。需要处理内部字段时选择深拷贝或容器 converter。
- 未知具体值的源 interface 需要运行时验证;无法静态判定时返回 `ErrDynamicType`。项目可给该接口类型对提供精确 converter 并承担内容验证。
- 已注册 converter 的签名被接受,但函数不在 Check 中执行;其返回错误、panic、金额规则不能靠预检证明。
- nil 到非可空目标、map 键转换碰撞、深拷贝结果是否残留源引用继续在 Copy 时检查。Check 成功不会关闭这些保护。
- 实际 Copy 也修正了空集合的检查缺口:确定的嵌套类型不匹配不再因为切片/map 为空或数组长度为 0 而通过。需要这些转换时提供显式 converter;目标仍只在整体成功后更新。
- 不引入全局配置、映射语言或业务类型;Copier.Check 可并发调用,不共享每次检查的遍历状态。
示例:[developerTools](examples/developerTools/README.md)。普通同名同类型字段不需要额外配置。
## 深拷贝与构造器
```go

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.20.0-preview)
# 通用业务开发指南(v0.25.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
@@ -100,44 +100,13 @@ defer reader.Close()
不声明 multipart 字段时,保留原来的 `body *httpx.Body` 流式入口,不创建临时文件。该方式适合特殊协议;项目应在不可撤销副作用之前完成校验并调用 `body.Complete()`。下载和原始回调继续使用 `@produces` / `@body(mode: raw)`。
## 完整业务配置的环境覆盖
## 文件声明配置来源
生成器维护 `internal/config/loadGen.go`;项目维护 Config/Default/Validate 和可选 NewLoader。三种格式、可扩展解析器、字段错误定位及旧 Load 迁移见 [统一配置指南](CONFIG.md)。
项目维护 Config、Default()、Validate() 和配置文件;生成器维护 Load/main。流程为默认值→选定文件→显式 `${NAME}` / `${NAME:-fallback}` 引用→类型绑定→校验。没有前缀、别名或 env/envDefault 隐式覆盖。
生成项目默认执行:代码与 `envDefault` 默认值 → JSON/YAML/XML 文件 → 全结构体环境覆盖(含别名)→ 规范化 → `Validate`。增加业务字段不需要另外写 `os.Getenv`。
固定值保持固定,缺失字段保留 Default(),0/false/空字符串正常覆盖;引用失败不回退初始化默认值。`$$` 转义 `$`,字符串凭据保留空格与特殊字符。
```go
type ClientConfig struct {
Endpoint string `json:"endpoint" env:"CLIENT_ENDPOINT,required"`
Token string `json:"-" env:"CLIENT_TOKEN,required,secret,empty=reject"`
Timeout time.Duration `json:"timeout" envDefault:"3s"`
Retries []int `json:"retries"`
Enabled bool `json:"enabled"`
}
```
把字段加入项目 `Config` 后,默认环境名取 `GO_OMNI_` 加 JSON 路径的 UPPER_SNAKE_CASE,如 `GO_OMNI_CLIENT_TIMEOUT`。显式 env 名是完整名称,可以使用自己的业务前缀。`env:"-"` 排除整个子树;`json:"-"` 字段只有显式 env 标签才参与。
duration 使用 `250ms`、`3s`;slice/map 使用 JSON,如 `[1,3,5]`、`{"retry":"2s"}`。支持嵌套、零/false、空数组、自定义 TextUnmarshaler。空环境值默认允许;可选择 `empty=ignore` 或 `empty=reject`。required 要求字符串非空、指针/集合非 nil;0 和 false 可以是合法业务值,范围由项目 Validate 决定。
手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
### 一次覆盖完整配置与 HTTP/日志短名称
```go
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{
Prefix: "MY_APP_",
Empty: "reject", // allow / ignore / reject,项目自行选择
Aliases: config.ServerEnvAliases("MY_APP_", "server"),
// Lookup 可注入测试或项目配置源;默认 os.LookupEnv。
})
```
不要再紧接着调用 `ApplyEnv(cfg.Server, ...)`。上面的调用同时处理完整配置和 HTTP_ADDR、SERVICE、LOG_LEVEL 等框架短名称。例如 `MY_APP_SERVER_ADDRESS` 与 `MY_APP_HTTP_ADDR` 指向同一字段,**完整名称优先**;未设置时才读取短名称。完整名称显式设空仍优先:allow 交给该字段解析(string 可清空;数字等仍要求合法值),ignore 保留文件/默认值,reject 报错。不会因 ignore 再退回别名。字段 `env` 标签的 empty 规则优先于全局规则。
自定义别名用 `EnvOptions.Aliases`,键是完整环境名、值是按优先级排列的别名数组。两种名称都使用同一 Lookup、空值策略和类型转换;布尔 false、数值 0 与未设置不同。集合始终使用 JSON,包括 `MY_APP_TRUSTED_PROXIES='["127.0.0.1"]'`。
新项目模板一次调用 OverlayEnv,默认 `Empty: "ignore"`。旧 `ApplyEnv` 保留原有忽略空值、修剪空白及 TRUSTED_PROXIES 逗号列表行为。已有项目的 `internal/config` 属于开发者文件,升级不覆盖它;按上例合并原来的两次覆盖调用后,保留项目自己的 Validate。日志输出的显式完整名称也优先于短名称。
`go run . -config etc/config.test.yaml -check-config` 只校验,不打开日志、数据库或端口。生成/check/dry-run 保留业务文件;新的模板要求 v0.22。完整格式、错误定位、解析器与移除清单见 [CONFIG.md](CONFIG.md)。
## 依赖、日志与中间件
@@ -280,7 +249,7 @@ go-omni workspace check --file omni.workspace.json
## v0.13 项目升级
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.20.0-preview(或显式引用当前源码)。
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.22.0-preview(或显式引用当前源码)。
```sh
go-omni api upgrade --dir . --dry-run

View File

@@ -22,7 +22,7 @@ app.GET("/metrics", measurements.Handler())
// 也可以直接读取 measurements.Snapshot()。
```
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。CORS 由项目中间件或代理配置,当前没有内置 CORS 模块。
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。需要跨域时配置 `server.cors`,由框架在路由与鉴权前处理预检;默认关闭。已有代理 CORS 可继续使用,避免两处重复设置。见 [CORS 配置与示例](cors/README.md)。
## 缓存:cache 与 cache/redis
@@ -70,30 +70,26 @@ application.OnShutdown(client.Close)
```go
client, err := httpc.New(httpc.Config{
Timeout: 3*time.Second, MaxRetries: 2, MaxConnsPerHost: 32,
MaxResponseBytes: 1 << 20,
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
Timeout: 3*time.Second, DialTimeout: time.Second,
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: time.Second,
MaxConnsPerHost: 32, MaxIdleConnsPerHost: 16, MaxIdleConns: 64,
IdleConnTimeout: 60*time.Second, MaxResponseBytes: 1<<20,
})
if err != nil { return err }
application.OnShutdown(client.Close)
err = client.JSON(ctx, "GET", serviceURL, nil, &result)
err = client.JSON(ctx, "GET", serviceURL, nil, &result,
httpc.WithHeader("Authorization", "Bearer "+token),
)
```
客户端应在服务启动时创建并复用。默认使用独立的 `net/http.Transport` 连接池,每个目标主机
有连接上限;该上限不是整个进程的全局连接数上限。也可以注入自有 `http.RoundTripper`,
其连接限制和生命周期由调用方负责。这样出站实现同样可以替换。
启动时创建并复用 Client。代理可选环境、直连、固定地址;不配置时保留标准库原代理策略。JSON 支持单次请求头和响应限额,不保存 Client 全局 Token。新阶段超时与空闲连接参数可选,0 沿用默认。
总超时覆盖建连、重试、等待和读取响应体。`Do` 返回后调用方必须读取/关闭 `response.Body`;
`JSON` 自动关闭,并限制解码前读取的字节数。`Do` 允许流式读取,本身不限制响应体大小。
`Close` 拒绝新请求、关闭自有池的空闲连接;在途请求应先排空。
成功响应读取有界;output=nil 流式丢弃,不整体缓存。小错误体只在大小/时间预算内尝试读完以复用连接;未知或大错误体及时关闭。Do 保留标准流式 Request/Response,由调用者限量读取并关闭 body。Close 拒绝新调用并关闭自有池空闲连接,项目先排空在途工作。
`MaxRetries` 默认 0,最多 5。封装只对可重放的 GET/HEAD/OPTIONS 重试网络错误及
429/502/503/504;POST/PUT/PATCH/DELETE 不做应用层自动重试。标准库 Transport 和借用的
RoundTripper 自身的重放规则仍生效,不能据此承诺“只执行一次”。退避带抖动,尊重
`Retry-After`;服务器要求等待超过 `MaxBackoff` 时直接返回该响应,不提前重试。
重定向交给业务处理,3xx 不会自动跟随。
自定义 Transport 借用、不修改、不关闭;不能同时指定池/代理/阶段选项。总超时持续覆盖 HTTP 等待、重试和读取 body,但不抢占 JSON 编解码或不遵守 context 的自定义代码。MaxRetries 默认 0,只对可重放 GET/HEAD/OPTIONS 重试;3xx 不跟随。标准库和借用 Transport 自身仍可能重放,不能据此承诺业务只执行一次。
默认从 context 传递 `X-Request-ID`,日志只包含方法、主机、状态、尝试次数和收到响应头的耗时,
不记录完整 URL、查询参数、凭证和响应体。非 2xx 的 JSON 调用返回只带状态码的 `StatusError`。
完整参数、NO_PROXY、TLS、安全日志、重试、签名兼容说明见 [httpc 指南](httpc/README.md),可运行 [HTTP Client 示例](examples/httpClient/README.md)。
## 请求保护:middleware

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界
v0.20.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
v0.25.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
@@ -143,7 +143,7 @@ server:
`responseMode: raw` 返回 `{"status":"ready"}` 等健康数据,有依赖时包含检查名称、结果与耗时;不包装公共体。存活检查不执行依赖探针;服务开始退出后,就绪检查返回 503,存活仍可返回 200,直到监听关闭。OpenAPI 始终输出规范文档本身,不使用业务响应包装;关闭 HTTP 文档端点不影响生成 `docs/openapi.json`。
环境变量覆盖配置文件,例如 `GO_OMNI_ENDPOINTS_READINESS_PATH=/readyz`、`GO_OMNI_ENDPOINTS_READINESS_RESPONSE_MODE=raw`、`GO_OMNI_ENDPOINTS_OPENAPI_ENABLED=false`;liveness/readiness/openapi 都支持对应的 ENABLED 和 PATH。Config/AppOptions 的最终有效配置决定是否注册。
内置路由配置由项目文件明确设置,也可以使用 `${NAME:-fallback}` 引用;不再按字段名自动读取环境变量。Config/AppOptions 的最终有效配置决定是否注册。
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
@@ -215,7 +215,7 @@ get /download (DownloadReq)
## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.20.0-preview:
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.25.0-preview:
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。

146
README.md
View File

@@ -2,7 +2,13 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.20.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**从这份 README 开始接入。** 当前版本 **`v0.25.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
**v0.24 改动**:新增可选 CORS,生成项目通过 `server.cors` 自动接入,支持预检、错误响应和明确的来源白名单;默认关闭。[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)。
**v0.23 改动**:配置校验定位具体字段和安全原因;`httpc.JSON` 可选限制成功状态;`copyx.Check` 无需样本预检类型;`logx` 支持实例独立时区。[四项完整示例](examples/developerTools/README.md)。
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
@@ -12,9 +18,9 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
契约支持 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
契约支持 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及文件显式环境引用与配置检查。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、文件显式配置引用、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
@@ -31,51 +37,101 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
| 浏览器跨域 CORS | `server.cors` / `omni.WithCORS`;独立 HTTP 用 `cors.New(...).Wrap(handler)` | [配置、默认值与协议边界](cors/README.md)、[鉴权与预检示例](examples/cors/README.md) |
| HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) |
| 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) |
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) |
| 公共响应与特殊协议 | `.api` 定义 response 并 `@app(defaultResponse: ...)`;Go 用 `WithResponse` / Encoder | [响应配置](RESPONSE.md)、[响应模板契约](examples/contracts/responses.api) |
| 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) |
| OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) |
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
| 文件配置与环境覆盖 | 默认值→JSON/YAML/XML→环境→校验;完整业务结构用 `config.LoadProject[T]` / 环境别名 | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持独立时区、轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
| 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;`InTx` 的 `TxError` 提供阶段、已知结果与原因链 | [通用数据库](database/README.md)、[事务失败示例](examples/transactions/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
| 出站 HTTP、熔断 | `httpc.New` 复用连接池与截止时间;`resilience.New` 隔离故障依赖 | [HTTP 用法](MODULES.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) |
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
## 浏览器跨域 CORS
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
```yaml
server:
cors:
enabled: true
allowedOrigins: [https://admin.example.com, https://merchant.example.com]
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
allowedHeaders: [Authorization, Content-Type]
exposedHeaders: [Content-Disposition, X-Request-ID]
allowCredentials: false
maxAge: 10m
```
不需要修改 main、Logic 或 `.api`。框架在鉴权前处理合法预检;实际请求仍鉴权,401/404/405/500 保持项目响应格式并附带允许来源的跨域头。没有 Origin 的服务端回调正常处理。配置只决定浏览器读取权限,不替代登录、验签或 CSRF 防护。
手写项目用 `omni.WithCORS(omni.CORSConfig{...})`,独立 HTTP 服务用 `cors.New(...).Wrap(handler)`;代理已经处理 CORS 时保持关闭。完整 [默认值与边界](cors/README.md)、[可运行示例](examples/cors/README.md)。
## 带认证的出站 HTTP
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
```go
client, err := httpc.New(httpc.Config{
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
Timeout: 3*time.Second, DialTimeout: time.Second,
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: time.Second,
MaxConnsPerHost: 32, MaxIdleConnsPerHost: 16, MaxIdleConns: 64,
})
if err != nil { return err }
// 在 svc 生命周期登记 client.Close;关闭前先排空使用它的工作。
err = client.JSON(ctx, "GET", endpoint, nil, &result,
httpc.WithHeader("Authorization", "Bearer "+token),
httpc.WithMaxResponseBytes(64<<10),
)
```
请求头只作用于本次调用,不把用户 Token 留在共享 Client。默认代理/参数保持原行为;也可选择环境代理或固定代理。JSON 自动关闭并限制响应,Do 保留下载/流式读取扩展。完整配置、重试边界和已有自定义 Transport 的迁移见 [httpc](httpc/README.md)。
```sh
go run ./examples/httpClient
```
## 统一配置加载
新生成项目的加载流程由框架管理。业务在 `internal/config/config.go` 增加字段、默认值和 `Validate`,无需改 main 或复制环境变量解析。JSON、YAML、XML 共用字段和校验;扩展格式在 `loader.go` 注册。
业务只维护 Config、Default()、Validate() 和配置文件。生成器维护 Load/main;文件缺失字段保留默认值,显式 0/false/空字符串正常覆盖,固定值不会被同名环境变量再次覆盖。
```go
// 项目 Config 中增加字段,自动支持 GO_OMNI_BATCH_SIZE:
BatchSize int `json:"batchSize" envDefault:"100"`
// 项目 Validate 中增加业务范围检查:
if c.BatchSize < 1 {
return omniConfig.Invalid("batchSize", "must be positive")
}
BatchSize int `json:"batchSize"` // 加入 Config;在 Default() 中设置 BatchSize: 100。
```
错误包含文件、字段、可用的行列或环境变量名;输入原值不自动写入错误。
```yaml
server:
address: "${HTTP_ADDR:-127.0.0.1:8080}"
batchSize: "${BATCH_SIZE:-100}"
password: "${DB_PASSWORD}"
sessionToken: "${SESSION_TOKEN:-}"
```
`${NAME}` 未设置或为空就报错;`${NAME:-value}` 明确指定备用值。`$$` 转义 `$`;密码中的冒号、换行、引号和 `${...}` 原样插入,不二次解释。JSON/YAML/XML 共用解析后的字段处理和 Go 类型绑定,错误同时定位文件、字段、变量及可用行列,不打印原值。
```sh
go run ./examples/configuration -format xml
DEMO_CLIENT_TIMEOUT=500ms go run ./examples/configuration
# 在生成项目中,仅检查选中的文件,不初始化依赖:
go run . -config etc/config.test.yaml -check-config
```
详见 [统一加载、解析器、迁移](CONFIG.md) 和 [完整示例](examples/configuration/README.md)。**旧项目须先迁移手写 Load,再重新生成**;生成器会保留业务配置并拒绝冲突,不静默覆盖。
完整 [语义与迁移](CONFIG.md)、[可运行示例](examples/configuration/README.md)。**v0.22 移除旧自动环境覆盖、前缀/别名及 env/envDefault 标签**,生成项目先迁移开发者配置再重新生成;文件中的响应模板 `${code}` 写成 `$${code}`,`.api` 保持原样。
## 选择接入方式
@@ -97,7 +153,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
```sh
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -254,7 +310,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.20.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -480,6 +536,28 @@ if err := copier.Copy(&view, input); err != nil { return err }
自定义函数的源/目标类型由 Go 泛型确定。深拷贝时返回原指针、原 map、子切片或嵌套旧引用都会报错;字符串/整数原值可以返回。差异规则、nil 策略、字段覆盖、资源限制和原 mapping 移除步骤见 [copyx 指南](COPY.md),完整类型及可运行代码见 [examples/copying](examples/copying/main.go)。工具使用运行时检查,类型对需测试;深拷贝不适用于连接、锁等资源。
### 减少接入代码:四个常用入口
```yaml
server:
log:
timeZone: Asia/Shanghai
```
时区由 Logger 实例管理,不修改全局 `time.Local`。非法日志级别、数据库端口/连接池等配置会指出 `server.log.level`、`database.port` 等字段和原因,不打印输入值。详见 [配置诊断](CONFIG.md#框架模块的字段诊断)、[日志时区](logx/README.md#实例时区)。
```go
// 本次请求只接受 HTTP 200;默认仍接受所有 2xx。
err := client.JSON(ctx, http.MethodGet, url, nil, &result,
httpc.WithSuccessStatuses(http.StatusOK),
)
// CI/启动时检查类型,无需构造样本;仍使用 Copy 的同一组 Option。
err = copyx.Check[Request, BusinessInput](options...)
```
状态不符合要求返回 `*httpc.StatusError`,不会写入 result,响应体仍由框架关闭。Check 不执行 converter;动态 interface、具体 nil/转换失败和深拷贝引用检查有明确边界,见 [类型预检](COPY.md#类型预检)。完整可运行代码见 [developerTools](examples/developerTools/README.md)。
### 数据库、存储及其他模块
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
@@ -498,20 +576,22 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
## 配置与运行
生成项目从 `etc/config.yaml` 读取配置,优先级为 **默认值 → 配置文件 → 环境变量**。密钥通过环境变量提供。手写入口需显式调用配置加载函数并传给 `omni.New(omni.WithConfig(settings))`;单独调用 `omni.New()` 不会自动读取环境变量。
生成项目使用 **Default() → 选定文件的显式引用 → 类型绑定 → Validate()**。固定值不会被环境变量覆盖;配置规则、转义及迁移见 [配置指南](CONFIG.md)。手写入口显式加载配置,再传入 `omni.New(omni.WithConfig(settings))`。
| 环境变量 | 用途 |
| 文件字段 | 用途 |
| --- | --- |
| `GO_OMNI_HTTP_ADDR` | 监听地址;生成项目默认 `127.0.0.1:8080` |
| `GO_OMNI_TRANSPORT` | `nethttp`(默认)、`native`、`http2` |
| `GO_OMNI_TLS_CERT_FILE` / `GO_OMNI_TLS_KEY_FILE` | 成对设置 TLS 证书和私钥路径 |
| `GO_OMNI_HTTP_READ_HEADER_TIMEOUT` / `GO_OMNI_HTTP_READ_TIMEOUT` | 请求头 / 请求读取超时,如 `5s` / `30s` |
| `GO_OMNI_HTTP_WRITE_TIMEOUT` / `GO_OMNI_HTTP_IDLE_TIMEOUT` | 响应写入 / Keep-Alive 空闲超时 |
| `GO_OMNI_HTTP_MAX_HEADER_BYTES` | 请求头大小限制,字节数 |
| `GO_OMNI_SHUTDOWN_TIMEOUT` | 优雅退出等待时间,默认 `10s` |
| `GO_OMNI_ENABLE_LOGGER` / `GO_OMNI_LOG_LEVEL` | 访问日志开关、日志级别 |
| `GO_OMNI_TRUSTED_PROXIES` | 可解析转发地址头的可信代理 IP/CIDR,逗号分隔 |
| `GO_OMNI_DB_HOST/USER/PASSWORD/DATABASE` | 官方 MySQL 适配器的结构化连接配置 |
| `server.address` | 监听地址,生成项目默认 127.0.0.1:8080 |
| `server.transport` | nethttp(默认)、native、http2 |
| `server.tls.certFile/keyFile` | 成对配置 TLS 证书和私钥路径 |
| `server.http.readHeaderTimeout/readTimeout` | 请求头 / 请求读取超时 |
| `server.http.writeTimeout/idleTimeout` | 响应写入 / Keep-Alive 空闲超时 |
| `server.http.maxHeaderBytes` | 请求头大小限制,字节数 |
| `server.shutdownTimeout` | 优雅退出等待时间,默认 10s |
| `server.enableLogger` / `server.log.level` | 访问日志开关、日志级别 |
| `server.trustedProxies` | 可信代理 IP/CIDR,文件数组 |
| `database.host/user/password/database` | 官方 MySQL 结构化连接配置 |
初始生成文件已为地址、网络层和选中模块的必要配置声明环境引用;其他字段按需要添加固定值或引用。
例如在生成项目中切换 native:
@@ -556,7 +636,7 @@ go test ./...
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
CLI 与业务 module 固定使用 `v0.20.0-preview`。v0.14–v0.19 项目先按 [配置入口迁移](CONFIG.md#v019-及更早项目迁移) 整理旧 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
CLI 与业务 module 固定使用 `v0.25.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入

View File

@@ -1,4 +1,124 @@
# v0.20.0-preview
# v0.25.0-preview
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
- `database.InTx` 签名不变,普通错误返回 `*database.TxError`;Stage 为 begin/work/commit,Outcome 为 notStarted/rolledBack/unknown。成功提交仍返回 nil。
- Commit 报错一律 unknown;回调失败且显式 Rollback 返回 nil 才标记 rolledBack。BeginTx 失败不执行回调。ErrTxDone 不作为回滚成功证据,框架不重试事务。
- `Err` 与 `RollbackErr` 保留两条原因链,可通过 errors.Is/As 识别;APIError、目录参数提示和公开响应继续工作。内部原因不自动进入 HTTP 提示。
- 提供无需外部数据库的 [故障演示](examples/transactions/README.md)。真实 MySQL 与性能结果见 [验收记录](reports/transactionAcceptance.md)。不引入新依赖,不包含文件删除、补偿任务或业务规则。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
go-omni api generate --check
go test ./...
```
**兼容边界:** 既有 errors.Is/As 保持;直接 `err == sentinel`、直接类型断言或字符串匹配需调整。nil context 使用 Background,nil 回调在开启前返回 typed error。panic/Goexit 继续传播并尝试回滚,不转换为 error;标准库自动回滚不公开的原因无法还原,不能推断已回滚。结果仅覆盖按约定使用的当前事务,不覆盖非事务表、隐式提交 SQL、其他连接或外部副作用。GORM 原生 Transaction 没有被替换。
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
---
# v0.24.0-preview(历史版本)
业务项目通过配置开启跨域,不需要手写 OPTIONS Handler 或逐条修改接口。
- 默认关闭,在 `server.cors` 显式配置来源、方法和请求头;统一文件加载、环境引用和字段级诊断自动生效。生成项目无需修改 main、svc 或 `.api`。
- 预检在鉴权前返回 204/403 空响应;实际请求继续鉴权、业务错误编码和路由处理。允许来源可读取 401/403/404/405/500,上传下载和流式能力保留。
- 只支持精确 HTTP(S) 来源,公开无凭据服务可配置单一 `*`;禁止 `*` 与凭据混用。配置初始化快照、请求并发隔离、Vary 缓存分流和有界请求头处理。
- 同一个 App 策略覆盖标准库、native 和 HTTP2;独立服务可使用 `cors.New(...).Wrap(handler)`。没有新增第三方依赖,没有移除现有功能。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
现有项目不配置 CORS 时行为不变。代理已处理 CORS 的项目继续关闭;跨域不替代登录、权限或 CSRF 防护。通过 App/Engine/Run 接入,直接对外暴露底层 Router 会绕过应用外层策略。本轮新增 runtime 配置需 v0.24,未使用该配置的模板最低版本仍为 v0.22。
[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)、[验收与性能](reports/corsAcceptance.md) 已同步。49 个测试包全量 race、Go 1.22、vet/build、实际生成项目、三种 transport 的 TLS 场景和 20 项真实浏览器检查通过;30 秒 fuzz 约 288 万次。当前仍为 preview。
空缓存安装远程版本后,9 组生成消费者和 70 项发布检查通过,耗时 66.78 秒,无本地 replace;包括下载模块的 cors/app race、三种 transport 的 TLS 检查和只改配置的生成项目。标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`,见 [远程记录](reports/corsRemote.json)。
---
# v0.23.0-preview(历史版本)
减少业务接入时重复维护的配置校验、HTTP 包装和类型检查。
- 配置诊断定位 database.port、database.pool.maxIdleConns、server.log.level/timeZone 等具体字段,保留文件、环境变量、行列和安全原因;凭据不进入公开提示,原始原因可 errors.Is/As。
- JSON 可传 `httpc.WithSuccessStatuses(200)`,默认仍接受全部 2xx;不匹配时不改目标,继续负责有界读取、关闭和连接回收,不添加新的重试规则。
- `copyx.Check[S,T](options...)` 或已有 Copier.Check 复用同一组 Go 选项,无需样本和映射文件;实际拷贝不再因集合为空而漏过确定的类型不匹配。动态 interface、nil 值、converter 失败及深拷贝共享引用仍有运行时检查。
- `server.log.timeZone: Asia/Shanghai` 配置实例时区;JSON/text/子日志一致,业务 time.Time 属性保持原样。极简镜像可用 `go build -tags timetzdata .` 内置时区数据,库不强制增加体积。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.23.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
现有调用接口保留;配置错误文本现在更精确,业务应通过类型/字段识别错误。空集合里确定的类型错误会提前暴露,需要显式 converter 或调整实际类型,不应靠空值跳过校验。预检通过不代表 converter 对所有业务值必定成功。当前模板最低版本仍是 v0.22,本轮新增 runtime 能力需使用 v0.23。
[完整示例](examples/developerTools/README.md)、[配置](CONFIG.md)、[JSON 选项](httpc/README.md)、[类型预检](COPY.md#类型预检)、[日志时区](logx/README.md#实例时区)。验收与性能记录见 [本轮验收](reports/developerToolsAcceptance.md)。仍为 preview。
48 个测试包全量 race、Go 1.22、vet/build、实际生成消费者及约 392 万次 copyx fuzz 通过。空缓存安装远程版本后,66 项发布检查通过,包括下载模块的 config/httpc/copyx/logx race;耗时 57.06 秒,无本地 replace。标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,见 [远程验收](reports/developerToolsRemote.json)。
---
# v0.22.0-preview(历史版本)
配置文件明确决定每个值的来源,项目只维护 Config、Default()、Validate() 和文件。
- `${NAME}` 必填,`${NAME:-fallback}` 在未设置/空字符串时使用备用值,`$$` 转义 `$`。支持字符串组合;环境值不二次展开,凭据空格和特殊字符原样保留。
- 在 JSON/YAML/XML 已解析的字段值上展开,再按目标 Go 类型绑定;缺失字段保留默认值,0/false/空字符串可显式覆盖。字段错误定位文件、环境名和可用行列,不回显值。
- 生成 Load/main 自动接入;新增 `-check-config`,选定一个文件检查,成功退出,不启动日志/依赖/监听。精简生成配置,重新生成保留业务 Config、Default、Validate 和文件。
- **不兼容变更:旧自动环境覆盖、前缀/别名、env/envDefault、database.ConfigFromEnv/ApplyEnv 已移除。** LoadFile/NewReloader 不再接收 prefix,omni.LoadConfig 参数改为文件路径。发现旧标签提示迁移。
- 配置文件中的响应模板用 `$${code}` 等转义;`.api` 中的 `${code}` 不变。日志 file/output/rotation 均由显式文件配置决定,不再隐式清除。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.22.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.22.0-preview
# 按 CONFIG.md 迁移开发者配置后:
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
[完整规则与迁移](CONFIG.md)、[可运行示例](examples/configuration/README.md)、[场景与性能验收](reports/explicitConfigurationAcceptance.md) 已同步。48 个测试包全量 race、实际生成消费者、Go 1.22、vet/build 和约 28 万次引用 fuzz 通过;小配置固定值/带引用加载约 25 µs,属于本机启动阶段基准。当前仍为 preview。
空缓存远程 CLI 安装、下载模块 config/httpc race、8 组实际生成消费者与 `-check-config` 验收通过,无本地 replace。标签固定在 `d598b2ebee8cde36859021e882a6bc1e0bb4fd4c`;见 [远程验收记录](reports/explicitConfigurationRemote.json)。
---
# v0.21.0-preview(历史版本)
让带认证的 HTTP 调用和内部直连可以直接使用 httpc,减少手写 Transport 与 JSON 响应处理。
- Proxy 支持环境、直连、固定代理;默认保留既有策略。
- 建连、TCP keepalive、TLS 握手、响应头、100-continue、空闲超时和独立空闲连接数量可选配置;不修改全局 Transport,不关闭借用实例。
- JSON 支持 WithHeader / WithHeaders / WithMaxResponseBytes;认证信息逐请求隔离,自动关闭和有界响应处理保留。
- 成功路径减少请求复制和无用退避;output=nil 按限额流式丢弃;小错误体在大小和时间预算内尝试复用连接。
- 不新增依赖、不增加业务规则;Do 保留标准流式扩展。CLI 最新版本与模板最低版本分开,本轮模板兼容门槛仍是 v0.20。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.21.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
```
**兼容注意**:JSON 旧的直接调用仍有效,自定义 interface/函数值需要同步新增的 variadic 参数。自定义 Transport 与显式连接/代理/阶段参数混用现在报错,避免参数被忽略;迁移时在自己的 Transport 配置,或改用内置字段。没有删除原调用入口。
[参数与使用说明](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[场景与性能验收](reports/httpcAcceptance.md) 已同步到 README。48 个测试包全量 race、Go 1.22 兼容、vet/build、约 110 万次请求头 fuzz 通过。性能来自本机同条件基准,不能代替项目实际流量验证;仍为 preview。
空缓存远程安装、下载模块的 httpc race 与示例、8 组生成消费者验收通过,无本地 replace。标签固定在 `96e7d9629b5c8b78e9cfefbcb05da0ab702c242b`,见 [远程记录](reports/httpcRemote.json)。
---
# v0.20.0-preview(历史版本)
统一配置加载,减少生成项目重复维护的启动代码。
@@ -18,7 +138,7 @@ go test ./...
go build .
```
完整用法、格式、脱敏边界和迁移见 [统一配置指南](CONFIG.md),可运行例子见 [examples/configuration](examples/configuration/README.md)。回归、模糊测试与启动加载性能见 [验收记录](reports/configurationAcceptance.md)。仍为 preview;项目的实际配置和业务依赖应自行验收。
完整用法、格式、脱敏边界和迁移见 [统一配置指南](CONFIG.md),可运行例子见 [examples/configuration](examples/configuration/README.md)。回归、模糊测试与启动加载性能见 [验收记录](reports/configurationAcceptance.md)。47 个测试包的全量 race、Go 1.22 兼容、vet/build、30 秒 fuzz 与 5 个基准场景三轮测量通过。空缓存远程安装和 8 组生成消费者回归通过,无本地 replace;标签固定在 `c60841f901a3bcc3e9582eece6cfb2cad008fb9e`,见 [远程记录](reports/configurationRemote.json)。仍为 preview;项目的实际配置和业务依赖应自行验收。
---
@@ -116,7 +236,7 @@ go-omni api generate --check
go test ./...
```
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 本版当时的统一覆盖方式合并 OverlayEnv 与 ApplyEnv(该模式已在 v0.22 移除,当前升级见 [配置迁移](CONFIG.md#v022-迁移旧模式已移除));共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。空缓存远程 CLI / 固定版本消费者验收也已通过,没有本地 replace;标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。

View File

@@ -12,6 +12,7 @@ import (
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/cors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
@@ -24,6 +25,7 @@ import (
// httpx.Router, leaving the network implementation replaceable.
type App struct {
router httpx.Router
handler http.Handler
config config.Config
middlewares []httpx.Middleware
closers []func() error
@@ -67,6 +69,11 @@ func configure(options ...config.Option) config.Config {
}
func newApp(router httpx.Router, settings config.Config) *App {
settings.CORS = settings.CORS.Clone()
corsPolicy, corsErr := cors.New(settings.CORS)
if corsErr != nil {
panic(config.AtField("cors", corsErr))
}
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
encoder, responseErr := response.New(settings.Response)
@@ -93,6 +100,7 @@ func newApp(router httpx.Router, settings config.Config) *App {
}
middlewares = append(middlewares, middleware.Recovery())
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
application.handler = corsPolicy.Wrap(router)
if fallback, ok := router.(httpx.FallbackRouter); ok {
adaptError := func(status int, code, message string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -104,21 +112,23 @@ func newApp(router httpx.Router, settings config.Config) *App {
return application
}
// Router returns the transport contract used by this application.
// Router exposes the underlying routing contract for advanced registration.
// Serve requests through App or Engine so application CORS policy is applied.
func (a *App) Router() httpx.Router { return a.router }
// Engine is kept as a compatibility name for the old API. It returns a
// standard http.Handler, so callers do not depend on a router type.
func (a *App) Engine() http.Handler { return a.router }
func (a *App) Engine() http.Handler { return a.handler }
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
a.router.ServeHTTP(writer, request)
a.handler.ServeHTTP(writer, request)
}
// Config returns the effective application configuration.
func (a *App) Config() config.Config {
settings := a.config
settings.CORS = settings.CORS.Clone()
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
@@ -177,7 +187,7 @@ func (a *App) NewServer(address string) *http.Server {
}
}
cfg := a.config.HTTP
return &http.Server{Addr: address, Handler: a.router,
return &http.Server{Addr: address, Handler: a.handler,
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}

187
app/cors_test.go Normal file
View File

@@ -0,0 +1,187 @@
package app_test
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func corsApp() *omni.App {
return omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST", "PUT"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 999, MessageField: "msg", DataMode: "always"}))
}
func TestAppCORSRoutingAndErrors(t *testing.T) {
a := corsApp()
defer a.Close()
var middlewareCalls int
a.Use(func(next omni.Handler) omni.Handler {
return func(c *omni.Context) error { middlewareCalls++; return next(c) }
})
a.GET("/secure", func(c *omni.Context) error { return c.OK("private") }, func(next omni.Handler) omni.Handler {
return func(c *omni.Context) error { return omni.Unauthorized("LOGIN_REQUIRED", "login required") }
})
a.GET("/failure", func(c *omni.Context) error { return omni.Internal(errors.New("private detail")) })
a.GET("/panic", func(c *omni.Context) error { panic("private panic") })
a.Handle("OPTIONS", "/options", func(c *omni.Context) error { return c.Text(200, "ordinary options") })
for name, entry := range map[string]http.Handler{"App": a, "Engine": a.Engine(), "NewServer": a.NewServer("").Handler} {
t.Run(name, func(t *testing.T) {
for _, tc := range []struct {
method, path string
status int
}{{"GET", "/secure", 401}, {"GET", "/failure", 500}, {"GET", "/panic", 500}, {"GET", "/absent", 404}, {"PUT", "/secure", 405}} {
r := httptest.NewRequest(tc.method, tc.path, nil)
r.Header.Set("Origin", "https://web.example")
w := httptest.NewRecorder()
entry.ServeHTTP(w, r)
var body map[string]any
err := json.Unmarshal(w.Body.Bytes(), &body)
if err != nil || w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" || body["code"] != float64(999) || body["data"] != nil || body["msg"] == nil || strings.Contains(w.Body.String(), "private") {
t.Fatal(w.Code, w.Header(), w.Body.String(), err)
}
}
for _, path := range []string{"/secure", "/absent"} {
before := middlewareCalls
r := httptest.NewRequest("OPTIONS", path, nil)
r.Header.Set("Origin", "https://web.example")
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Set("Access-Control-Request-Headers", "authorization,content-type")
w := httptest.NewRecorder()
entry.ServeHTTP(w, r)
if w.Code != 204 || w.Body.Len() != 0 || middlewareCalls != before {
t.Fatal(w.Code, w.Body.String(), middlewareCalls, before)
}
}
r := httptest.NewRequest("OPTIONS", "/options", nil)
w := httptest.NewRecorder()
entry.ServeHTTP(w, r)
if w.Code != 200 || w.Body.String() != "ordinary options" {
t.Fatal(w.Code, w.Body.String())
}
})
}
}
func TestAppCORSConfigIsolation(t *testing.T) {
cfg := config.Default()
cfg.CORS = omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://one.example"}, AllowedHeaders: []string{"Authorization"}}
option := omni.WithConfig(cfg)
cfg.CORS.AllowedOrigins[0] = "https://evil.example"
a := omni.New(option)
defer a.Close()
b := omni.New(option)
defer b.Close()
a.Config().CORS.AllowedOrigins[0] = "https://evil.example"
for _, app := range []*omni.App{a, b} {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/absent", nil)
r.Header.Set("Origin", "https://one.example")
app.ServeHTTP(w, r)
if w.Header().Get("Access-Control-Allow-Origin") != "https://one.example" {
t.Fatal(w.Header())
}
}
defer func() {
if recover() == nil {
t.Error("invalid CORS passed direct New")
}
}()
omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true}))
}
func TestCORSTransports(t *testing.T) {
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative, omni.TransportHTTP2} {
t.Run(transport, func(t *testing.T) {
cert, key, tlsClient := testutil.CertificateFiles(t)
cfg := omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST"}, AllowedHeaders: []string{"Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}
a := omni.New(omni.WithCORS(cfg), omni.WithTransport(transport), omni.WithTLS(cert, key))
var called atomic.Int32
a.Use(func(next omni.Handler) omni.Handler {
return func(c *omni.Context) error { called.Add(1); return next(c) }
})
a.POST("/upload", func(c *omni.Context) error {
b, err := io.ReadAll(c.Request().Body)
if err != nil {
return err
}
return c.Text(200, string(b))
})
a.GET("/download", func(c *omni.Context) error {
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: io.NopCloser(strings.NewReader("file bytes")), Name: "example.txt"})
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- a.ServeContext(ctx, listener) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("server did not close")
listener.Close()
}
}()
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
defer client.CloseIdleConnections()
url := "https://" + listener.Addr().String()
for _, tc := range []struct {
method, path, body string
status int
}{{"OPTIONS", "/upload", "", 204}, {"POST", "/upload", "raw upload", 200}, {"GET", "/download", "file bytes", 200}, {"GET", "/missing", "", 404}} {
var input io.Reader
if tc.method == "POST" {
input = strings.NewReader(tc.body)
}
r, _ := http.NewRequest(tc.method, url+tc.path, input)
r.Header.Set("Origin", "https://web.example")
if tc.method == "OPTIONS" {
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Set("Access-Control-Request-Headers", "content-type")
}
res, err := client.Do(r)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatal(err)
}
if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") != "https://web.example" {
t.Fatal(res.StatusCode, res.Header)
}
if tc.status == 200 && string(body) != tc.body {
t.Fatal(string(body))
}
if tc.status == 204 && (len(body) != 0 || called.Load() != 0) {
t.Fatal("preflight reached middleware")
}
if tc.path == "/download" && (!strings.Contains(res.Header.Get("Content-Disposition"), "example.txt") || res.Header.Get("Access-Control-Expose-Headers") != "Content-Disposition") {
t.Fatal(res.Header)
}
if transport == omni.TransportHTTP2 && res.ProtoMajor != 2 {
t.Fatal(res.Proto)
}
}
})
}
}

View File

@@ -14,7 +14,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
```sh
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -49,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.20.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.25.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
```sh
# 在 go-omni 源码仓库中执行
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
```
当前模板与依赖以 v0.20.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.25.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令
@@ -70,6 +70,8 @@ go-omni migrate --dir migrations --apply
SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。
### v0.20 配置入口
### v0.22 文件显式配置
新生成 `internal/config/loadGen.go` 调用统一加载,纳入生成检查;项目 config.go 只维护字段、默认值和校验,loader.go 用于注册额外格式。JSON/YAML/XML 内置。旧手写 Load 会阻止生成,请先按 [迁移步骤](../../CONFIG.md#v019-及更早项目迁移) 整理,不会自动覆盖业务代码。
新生成 `internal/config/loadGen.go` 调用统一加载,纳入生成检查;项目 config.go 只维护字段、默认值和校验,loader.go 用于注册额外格式。JSON/YAML/XML 内置。旧手写 Load 会阻止生成,请先按 [迁移步骤](../../CONFIG.md#v022-迁移旧模式已移除) 整理,不会自动覆盖业务代码。
`go run . -config etc/config.test.yaml -check-config` 只做配置解析和校验,不初始化日志、数据库或监听。文件中的固定值不受环境自动覆盖;`${NAME}` 为必填引用,`${NAME:-fallback}` 提供备用值。

View File

@@ -26,8 +26,8 @@ func TestGeneratedConfigurationConsumer(t *testing.T) {
// The business only changes fields/defaults/validation, not the loading flow.
writeFile(t, filepath.Join(dir, "internal/config/config.go"), `package config
import("git.yuntuo.hk/k2safe/go-omni/config";"git.yuntuo.hk/k2safe/go-omni/logx")
type Config struct { Server config.Config `+"`json:\"server\"`"+`;Limit int `+"`json:\"limit\" envDefault:\"10\"`"+` }
func Default()Config{ c:=config.Default();c.Log.File="./var/log/app.log";c.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:10};return Config{Server:c} }
type Config struct { Server config.Config `+"`json:\"server\"`"+`;Limit int `+"`json:\"limit\"`"+` }
func Default()Config{ c:=config.Default();c.Log.File="./var/log/app.log";c.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:10};return Config{Server:c,Limit:10} }
func(c Config)Validate()error{if err:=c.Server.Validate();err!=nil{return config.AtField("server",err)};if c.Limit<0{return config.Invalid("limit","must not be negative")};return nil}
`)
writeFile(t, filepath.Join(dir, "internal/config/loader.go"), `package config
@@ -111,3 +111,26 @@ func TestConfigLoadMigrationGuard(t *testing.T) {
}
}
}
func TestRemovedEnvironmentTagsRejectBeforeGeneration(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
o := Options{Dir: dir, Module: "example.com/migrate", FrameworkDir: frameworkRoot(t)}
if _, err := New(o); err != nil {
t.Fatal(err)
}
o.API = filepath.Join(dir, "api/service.api")
path := filepath.Join(dir, "internal/config/config.go")
source := readFile(t, path)
writeFile(t, path, strings.Replace(source, "json:\"server\"", "json:\"server\" env:\"OLD_SERVER\"", 1))
before := snapshot(t, dir)
for _, mode := range []string{"dry", "generate", "check"} {
o.DryRun = mode == "dry"
o.Check = mode == "check"
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "env/envDefault") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("migration rejection wrote files")
}
}
}

View File

@@ -0,0 +1,59 @@
package apigen
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestGeneratedCORSConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "app")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service corsdemo {\n@handler ping\nget /ping\n}\n")
o := Options{API: api, Dir: dir, Module: "example.com/corsdemo", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/cors/consumer_test.go"))
writeFile(t, filepath.Join(dir, "etc/config.yaml"), "server:\n log:\n output: discard\n cors:\n enabled: true\n allowedOrigins: [https://web.example]\n allowedMethods: [GET, POST, PUT]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n")
runConsumerGo(t, dir, "mod", "tidy")
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
runConsumerGo(t, dir, "build", ".")
o.API = filepath.Join(dir, "api/service.api")
before := snapshot(t, dir)
for _, mode := range []string{"dry", "generate", "check"} {
o.DryRun = mode == "dry"
o.Check = mode == "check"
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed project files")
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
cmd := exec.Command("go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(out))
}
var dependency struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal(out, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}

View File

@@ -19,7 +19,10 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.20.0-preview"
// Runtime-only releases need not raise the managed template compatibility floor.
const minimumVersion = "v0.22.0-preview"
const currentVersion = "v0.25.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
@@ -155,7 +158,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
if !filepath.IsAbs(path) {
path = filepath.Join(directory, path)
}
if source, err := os.ReadFile(filepath.Join(path, "config", "loader.go")); err == nil && strings.Contains(string(source), "func NewLoader(") {
if source, err := os.ReadFile(filepath.Join(path, "config", "references.go")); err == nil && strings.Contains(string(source), "func WithLookupEnv(") {
localCurrent = true
}
}
@@ -265,7 +268,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
return Output{}, errors.New("generation configuration changed during generation; retry")
}
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
expectedVersion := minimumVersion
expectedVersion := currentVersion
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
expectedVersion = info.Main.Version
}
@@ -304,7 +307,7 @@ func moduleFile(o Options) ([]byte, error) {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" {
version = info.Main.Version
} else {
version = minimumVersion
version = currentVersion
}
}
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" {

View File

@@ -63,18 +63,23 @@ func TestAPIInfrastructureConsumers(t *testing.T) {
t.Fatal(err)
}
test := `package config
import("testing";"os")
import("testing";"os";"strings")
func TestConfiguration(t *testing.T){
t.Setenv("GO_OMNI_DB_DSN","test:test@tcp(127.0.0.1:1)/test")
t.Setenv("GO_OMNI_DB_DATABASE","test");t.Setenv("GO_OMNI_DB_USER","test");t.Setenv("GO_OMNI_DB_PASSWORD","test")
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY","01234567890123456789012345678901")
t.Setenv("GO_OMNI_STORAGE_ACCESS_KEY","test");t.Setenv("GO_OMNI_STORAGE_SECRET_KEY","test")
t.Setenv("GO_OMNI_STORAGE_BUCKET","test");t.Setenv("GO_OMNI_STORAGE_ENDPOINT","ENDPOINT")
t.Setenv("GO_OMNI_STORAGE_ACCOUNT_ID","01234567890123456789012345678901")
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
t.Setenv("GO_OMNI_SERVER_LOG_FILE","./explicit.log");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!="./explicit.log"{t.Fatalf("canonical log file: %v",err)}
t.Setenv("GO_OMNI_SERVER_LOG_OUTPUT","stderr");cfg,err=Load("");if err!=nil||cfg.Server.Log.Output!="stderr"{t.Fatalf("canonical output: %v",err)}
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");t.Setenv("GO_OMNI_SERVER_LOG_FILE","./implicit.log")
cfg,err=Load("../../etc/config.yaml");if err!=nil||cfg.Server.Log.File!=Default().Server.Log.File{t.Fatalf("implicit environment override: %v",err)}
raw,err:=os.ReadFile("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
// Add explicit fields while keeping storage and DB requirements.
raw=[]byte(strings.Replace(string(raw),"server:\n","server:\n log:\n output: '${OUTPUT:-stdout}'\n file: ''\n rotation:\n enabled: false\n",1))
path:="explicit.yaml";if err=os.WriteFile(path,raw,0600);err!=nil{t.Fatal(err)};defer os.Remove(path)
cfg,err=Load(path);if err!=nil||cfg.Server.Log.Output!="stdout"||cfg.Server.Log.File!=""||cfg.Server.Log.Rotation.Enabled{t.Fatalf("explicit file override: %v",err)}
_=os.Getenv("unused")
}`
endpoint := ""

View File

@@ -19,8 +19,10 @@ import (
"MODULE/internal/svc"
)
func main(){
path:=flag.String("config","etc/config.yaml","configuration file; empty uses defaults and environment")
path:=flag.String("config","etc/config.yaml","configuration file; empty uses only Default()")
check:=flag.Bool("check-config",false,"validate configuration and exit without starting dependencies")
flag.Parse()
if *check {if _,err:=config.Load(*path);err!=nil{log.Fatal(err)};return}
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
defer stop()
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
@@ -58,7 +60,12 @@ Unimplemented/nil factories fail startup and clean registered dependencies.
Existing projects retain middleware: handlers until explicitly migrated.
Ordinary Logic uses the generated Request/Response types directly. Where an
independent business type is needed, use ordinary Go or the optional copyx package.
Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use one pipeline. See the framework CONFIG.md.
Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use explicit ${NAME} / ${NAME:-fallback} references; fixed values are never overridden by inferred environment names. Use -check-config to validate without opening dependencies. Framework validation
identifies fields such as server.log.level and database.port. server.log.timeZone
optionally selects a per-logger IANA zone. Optional server.cors is loaded and installed
automatically before routing/authentication; disabled by default. Configure allowedOrigins,
allowedMethods and allowedHeaders in the selected file; no main/handler edits.
See the framework CONFIG.md and cors/README.md.
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
See COPY.md in the framework repository for copy policies and migration steps,
@@ -77,7 +84,7 @@ Configure server.endpoints to disable or move each built-in route. Health
responseMode is app by default (project format), or raw for plain health data.
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
The default file explicitly references GO_OMNI_TRANSPORT; set it to native or edit server.transport.
The greeting starter works immediately; other new operation stubs return 501 until
their business logic is implemented. Authentication hooks also fail closed with 501.
@@ -139,7 +146,7 @@ Full reference: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/DEVELOPMENT
api/ # editable contract and imported types
main.go # GENERATED startup, lifecycle and route registration
etc/config.yaml # lowerCamelCase configuration
internal/config/ # defaults -> file -> environment
internal/config/ # defaults -> file references -> typed binding -> validation
internal/svc/ # dependency initialization and cleanup
internal/types/*Gen.go # DTOs split by source API, one types package
internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
@@ -240,13 +247,16 @@ func infrastructureREADME(o Options) string {
Selected database: %s; storage: %s; logging: %s/%s.
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
Use structured database/mysql configuration and GO_OMNI_DB_PASSWORD. Other connection/pool/TLS settings live in etc/config.yaml and GO_OMNI_DB_*.
Use structured database/mysql configuration. The generated etc/config.yaml explicitly
references GO_OMNI_DB_PASSWORD and other required connection values; add fixed
values or explicit references for pool/TLS fields. No implicit environment overlay.
No tables are invented or migrated automatically from request/response DTOs.
The none/memory selection opens no database; implement repositories per business.
With storage enabled, services receive deps.Files, Uploads, Downloads and
UploadLinks capability interfaces. Local signed GET/HEAD/PUT uses /storage/object;
set GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and GO_OMNI_STORAGE_PUBLIC_URL.
the generated file references GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and
GO_OMNI_STORAGE_PUBLIC_URL. File values may instead be fixed explicitly.
Cloud credentials use GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY; set bucket and endpoint
or R2 accountId as appropriate. Signed URLs grant temporary access: authenticate
and authorize before issuing them. No public upload-link route is added.

View File

@@ -6,28 +6,26 @@ import (
"example.com/configuration/internal/config"
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestGeneratedConfiguration(t *testing.T) {
for ext, source := range map[string]string{
"json": `{"limit":7}`, "yaml": "limit: 7\n", "xml": "<config><limit>7</limit></config>", "custom": `{"limit":7}`,
} {
for ext, source := range map[string]string{"json": `{"limit":"${LIMIT:-7}"}`, "yaml": "limit: '${LIMIT:-7}'\n", "xml": "<config><limit>${LIMIT:-7}</limit></config>", "custom": `{"limit":"${LIMIT:-7}"}`} {
t.Run(ext, func(t *testing.T) {
t.Setenv("LIMIT", "")
t.Setenv("GO_OMNI_LIMIT", "999")
path := filepath.Join(t.TempDir(), "app."+ext)
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatal(err)
if err != nil || cfg.Limit != 7 {
t.Fatal(cfg.Limit, err)
}
if cfg.Limit != 7 {
t.Fatal(cfg.Limit)
}
t.Setenv("GO_OMNI_LIMIT", "0")
t.Setenv("LIMIT", "0")
cfg, err = config.Load(path)
if err != nil || cfg.Limit != 0 {
t.Fatal(cfg.Limit, err)
@@ -36,24 +34,80 @@ func TestGeneratedConfiguration(t *testing.T) {
}
t.Setenv("GO_OMNI_LOG_OUTPUT", "stderr")
cfg, err := config.Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Limit != 10 || cfg.Server.Log.File != "" || cfg.Server.Log.Rotation.Enabled {
t.Fatal("defaults/log normalization", cfg, err)
if err != nil || cfg.Limit != 10 || cfg.Server.Log.File != "./var/log/app.log" || !cfg.Server.Log.Rotation.Enabled {
t.Fatal("implicit overlay", err)
}
path := filepath.Join(t.TempDir(), "bad.yaml")
os.WriteFile(path, []byte("limit: -1\n"), 0600)
// Validation must run before svc.Initialize; the project initializer panics
// below if reached, so this also detects accidentally starting dependencies.
os.WriteFile(path, []byte("limit: '${LIMIT}'\n"), 0600)
t.Setenv("LIMIT", "-1")
// svc.Init panics if reached. Both invalid startup and successful check must
// stop before opening the configured file logger or project dependencies.
err = run(context.Background(), path)
var diagnostic *omniConfig.Error
if !errors.As(err, &diagnostic) || diagnostic.Field != "limit" || diagnostic.File != path || diagnostic.Line != 1 {
var d *omniConfig.Error
if !errors.As(err, &d) || d.Field != "limit" || d.File != path || d.Line != 1 || d.Env != "LIMIT" {
t.Fatal(err)
}
t.Setenv("GO_OMNI_LIMIT", "private-not-a-number")
t.Setenv("LIMIT", "private-invalid")
_, err = config.Load(path)
if !errors.As(err, &diagnostic) || diagnostic.Env != "GO_OMNI_LIMIT" || strings.Contains(err.Error(), "private-not-a-number") {
if !errors.As(err, &d) || d.Env != "LIMIT" || strings.Contains(err.Error(), "private-invalid") {
t.Fatal(err)
}
}
func TestConfigurationCheckProcess(t *testing.T) {
exe := filepath.Join(t.TempDir(), "server")
cmd := exec.Command("go", "build", "-o", exe, ".")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
dir := t.TempDir()
path := filepath.Join(dir, "selected.yaml")
if err := os.WriteFile(path, []byte("limit: '${LIMIT:-0}'\nserver:\n address: 127.0.0.1:1\n"), 0600); err != nil {
t.Fatal(err)
}
// A broken neighboring default/local/test file must never be layered in.
for _, name := range []string{"config.yaml", "config.local.yaml", "config.test.yaml"} {
os.WriteFile(filepath.Join(dir, name), []byte("broken: ["), 0600)
}
cmd = exec.Command(exe, "-config", path, "-check-config")
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil || len(out) != 0 {
t.Fatal(err, string(out))
}
if _, err := os.Stat(filepath.Join(dir, "var")); !os.IsNotExist(err) {
t.Fatal("check opened logger/dependencies")
}
os.WriteFile(path, []byte("limit: '${MISSING_REQUIRED}'"), 0600)
cmd = exec.Command(exe, "-config", path, "-check-config")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "MISSING_REQUIRED=")
out, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(out), "MISSING_REQUIRED") || strings.Contains(string(out), "panic") {
t.Fatal(err, string(out))
}
cmd = exec.Command(exe, "-config", path)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "MISSING_REQUIRED=")
if out, err := cmd.CombinedOutput(); err == nil || strings.Contains(string(out), "panic") {
t.Fatal(err, string(out))
}
}
func TestGeneratedModuleFieldDiagnostic(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
t.Setenv("EXAMPLE_LOG_LEVEL", "invalid-private-level")
if err := os.WriteFile(path, []byte("server:\n log:\n level: '${EXAMPLE_LOG_LEVEL}'\n"), 0600); err != nil {
t.Fatal(err)
}
err := run(context.Background(), path)
var d *omniConfig.Error
if !errors.As(err, &d) || d.Field != "server.log.level" || d.Line != 3 || d.Env != "EXAMPLE_LOG_LEVEL" || !strings.Contains(d.Reason, "debug") || strings.Contains(err.Error(), "invalid-private-level") {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("server:\n log:\n timeZone: Asia/Shanghai\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(path)
if err != nil || cfg.Server.Log.TimeZone != "Asia/Shanghai" {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,69 @@
package main
import (
"context"
"errors"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"example.com/corsdemo/internal/config"
"example.com/corsdemo/internal/handler"
"example.com/corsdemo/internal/svc"
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestGeneratedCORSConfiguration(t *testing.T) {
cfg, err := config.Load("etc/config.yaml")
if err != nil {
t.Fatal(err)
}
logger, err := logx.New(cfg.Server.Log)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deps, err := svc.New(context.Background(), logger, cfg)
if err != nil {
t.Fatal(err)
}
defer deps.Close()
a, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
defer a.Close()
for _, tc := range []struct {
method, path string
status int
}{{"GET", "/ping", 501}, {"GET", "/missing", 404}, {"PUT", "/ping", 405}, {"GET", "/health/live", 200}, {"GET", "/health/ready", 200}, {"GET", "/openapi.json", 200}, {"OPTIONS", "/ping", 204}} {
r := httptest.NewRequest(tc.method, tc.path, nil)
r.Header.Set("Origin", "https://web.example")
if tc.method == "OPTIONS" {
r.Header.Set("Access-Control-Request-Method", "GET")
r.Header.Set("Access-Control-Request-Headers", "authorization")
}
w := httptest.NewRecorder()
a.ServeHTTP(w, r)
if w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" {
t.Fatal(tc, w.Code, w.Header(), w.Body.String())
}
if tc.status == 204 && (w.Body.Len() != 0 || w.Header().Get("Access-Control-Max-Age") != "600") {
t.Fatal(w)
}
}
// Startup config validation uses the normal generated run/Load, before log
// files or dependencies. No changes to generated main/HTTP composition.
path := filepath.Join(t.TempDir(), "invalid.yaml")
if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins: ['*']\n allowCredentials: true\n"), 0600); err != nil {
t.Fatal(err)
}
err = run(context.Background(), path)
var d *omniConfig.Error
if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || !strings.Contains(d.Reason, "allowCredentials") {
t.Fatal(err)
}
}

View File

@@ -123,7 +123,7 @@ func TestGeneratedTransfers(t *testing.T) {
func configFile(t *testing.T, address string, fail bool) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address}, "failInit": fail})
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address, "transport": "${GO_OMNI_TRANSPORT:-nethttp}"}, "failInit": fail})
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}

View File

@@ -24,7 +24,7 @@ func phasedConfig(t *testing.T, address, mode string, fail bool) string {
if mode == "stuck" {
drainTimeout = "50ms"
}
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "transport": "${GO_OMNI_TRANSPORT:-nethttp}", "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}

View File

@@ -14,7 +14,9 @@ import (
"golang.org/x/mod/semver"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
)
@@ -54,6 +56,22 @@ func checkConfigLoadExtension(root string) error {
if err != nil {
return fmt.Errorf("parse %s: %w", rel, err)
}
var removed bool
ast.Inspect(f, func(n ast.Node) bool {
field, ok := n.(*ast.Field)
if ok && field.Tag != nil {
if raw, e := strconv.Unquote(field.Tag.Value); e == nil {
tag := reflect.StructTag(raw)
_, a := tag.Lookup("env")
_, b := tag.Lookup("envDefault")
removed = removed || a || b
}
}
return true
})
if removed {
return fmt.Errorf("%s: env/envDefault tags were removed; migrate defaults and explicit file references before generating; see CONFIG.md", rel)
}
for _, decl := range f.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil && fn.Name.Name == "Load" {
return fmt.Errorf("%s: migrate the project-owned Load function before generating internal/config/loadGen.go; retain Config/Default/Validate and move decoder options to NewLoader; see CONFIG.md", rel)

View File

@@ -39,7 +39,7 @@ func oldInfrastructureProject(t *testing.T) string {
raw, _ := json.Marshal(m)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
path := filepath.Join(dir, "go.mod")
writeFile(t, path, strings.ReplaceAll(readFile(t, path), minimumVersion, "v0.13.0-preview"))
writeFile(t, path, strings.ReplaceAll(readFile(t, path), currentVersion, "v0.13.0-preview"))
return dir
}
@@ -74,7 +74,7 @@ func TestUpgradeV014PreservesExtensions(t *testing.T) {
raw, _ := json.Marshal(m)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
mod := filepath.Join(dir, "go.mod")
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), minimumVersion, "v0.14.1-preview"))
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), currentVersion, "v0.14.1-preview"))
owned := map[string]string{}
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
path := filepath.Join(dir, name)

View File

@@ -8,7 +8,7 @@ import (
)
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
for _, flag := range [][]string{{"--framework-version", "v0.20.0-preview"}, {"--framework-version=v0.20.0-preview"}} {
for _, flag := range [][]string{{"--framework-version", "v0.22.0-preview"}, {"--framework-version=v0.22.0-preview"}} {
project := filepath.Join(t.TempDir(), "billing")
args := append([]string{"--module", "example.com/billing"}, flag...)
if err := runNew(append(args, project)); err != nil {
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "go-omni v0.20.0-preview") || strings.Contains(string(content), "replace ") {
if !strings.Contains(string(content), "go-omni v0.22.0-preview") || strings.Contains(string(content), "replace ") {
t.Fatalf("CLI did not pin remote dependency: %s", content)
}
}

View File

@@ -66,9 +66,13 @@ func TestGenerateModuleProfiles(t *testing.T) {
t.Fatal("replacement does not resolve", err)
}
cfg, _ := os.ReadFile(filepath.Join(project, "etc/config.yaml"))
if strings.Contains(string(cfg), "secretKey:") || strings.Contains(string(cfg), "filesToken:") {
t.Fatal("secret defaults emitted")
for _, line := range strings.Split(string(cfg), "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), ":")
if ok && (key == "secretKey" || key == "filesToken" || key == "signingKey") && !strings.Contains(value, "${GO_OMNI_") {
t.Fatal("secret must be an explicit reference")
}
}
})
}
}
@@ -76,6 +80,7 @@ func TestModuleOptionsRejectBeforeWriting(t *testing.T) {
for _, options := range []Options{
{LogOutput: "invalid"}, {LogFormat: "xml"}, {Storage: "ftp"},
{Storage: "local", FrameworkVersion: "v0.6.0-preview"},
{FrameworkVersion: "v0.21.0-preview"},
{Module: "example.com/x\nrequire malicious v1.0.0"},
} {
options.Dir = filepath.Join(t.TempDir(), "project")

View File

@@ -34,9 +34,13 @@ type Options struct {
}
var files = map[string]string{
"internal/settings/config.go": "templates/modules-settings.go.tmpl",
"internal/settings/config_test.go": "templates/modules-settings_test.go.tmpl",
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
"go.mod": "templates/go.mod.tmpl",
"README.md": "templates/README.md.tmpl",
"cmd/server/main.go": "templates/cmd-server-main.go.tmpl",
"cmd/server/main.go": "templates/modules-main.go.tmpl",
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
"internal/model/errors.go": "templates/internal-model-errors.go.tmpl",
"internal/repository/users.go": "templates/internal-repository-users.go.tmpl",
@@ -109,7 +113,7 @@ func Generate(options Options) error {
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
replace = frameworkReplace(directory, options.FrameworkDir)
}
if modules && replace == "" && semver.Compare(version, modulesFrameworkVersion) < 0 && version != installedFrameworkVersion() {
if replace == "" && semver.Compare(version, modulesFrameworkVersion) < 0 && version != installedFrameworkVersion() {
return fmt.Errorf("module templates require %s or newer; use --framework-dir for unpublished source", modulesFrameworkVersion)
}
data := templateData{

View File

@@ -39,7 +39,7 @@ func TestGenerateCreatesServiceLayout(t *testing.T) {
if err != nil {
t.Fatalf("read generated server: %v", err)
}
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(ctx, logger)", "services.Health.ReadinessHandler()", "application.RunContext(ctx, settings.Address)"} {
for _, expected := range []string{"settings.Load(path)", "logx.SetDefault(logger)", "omni.WithConfig(cfg.Server)", "servicecontext.New(ctx, logger, cfg)", "services.Health.ReadinessHandler()", "application.RunContext(ctx, cfg.Server.Address)"} {
if !strings.Contains(string(mainContent), expected) {
t.Fatalf("generated server missing %q: %s", expected, mainContent)
}
@@ -110,7 +110,7 @@ func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
}
func TestGenerateRemoteProjectPinsVersion(t *testing.T) {
for _, version := range []string{"v0.1.0", "v1.2.3-rc.1", "v0.0.0-20260920140624-8b19aa79eea0"} {
for _, version := range []string{"v0.22.0-preview", "v1.2.3-rc.1", "v0.22.1-0.20260926000000-8b19aa79eea0"} {
t.Run(version, func(t *testing.T) {
project := filepath.Join(t.TempDir(), "billing")
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err != nil {

View File

@@ -32,46 +32,44 @@ go mod tidy
go run ./cmd/server
```
运行时配置从 `GO_OMNI_*` 环境变量加载,例如:
配置入口是 `internal/settings.Config`、`Default()`、`Validate()` 和 `etc/config.yaml`。
只有文件里的 `${NAME}` / `${NAME:-fallback}` 会读取环境变量,固定值不受进程环境覆盖。生成的文件已声明地址、网络层,以及所选数据库/存储的必要引用;其他参数直接写进文件。
```sh
GO_OMNI_ENABLE_LOGGER=true GO_OMNI_LOG_LEVEL=debug go run ./cmd/server
```yaml
server:
address: "${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}"
transport: "${GO_OMNI_TRANSPORT:-nethttp}"
log:
level: debug
shutdownTimeout: 10s
```
`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和
`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。
服务使用 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`。部署时构建并运行服务二进制,
使信号直接到达服务进程:
用 `-config` 选择一个 YAML/JSON/XML 文件;`-config ''` 只使用 Default();`-check-config` 仅加载和校验,不创建依赖或监听:
```sh
go run ./cmd/server -config etc/config.yaml -check-config
go build -o server ./cmd/server
./server
./server -config etc/config.yaml
```
`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。
服务通过 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`,先排空 HTTP 请求,再关闭依赖和日志。生成项目默认提供 `/health/live` 和 `/health/ready`,业务依赖集中在 `internal/servicecontext`。
使用支持统一网络配置的框架版本时,设置 `GO_OMNI_TRANSPORT=native` 即可切换为自研 HTTP/1.1;
默认 `nethttp`。`GO_OMNI_TLS_CERT_FILE` 和 `GO_OMNI_TLS_KEY_FILE` 配对启用 HTTPS。
实验版 HTTP/2 使用 `GO_OMNI_TRANSPORT=http2`,必须同时配置 TLS 证书和私钥。
业务 Handler / Service / Repository 不需要修改;三种适配器共用上述退出等待时间。
通过文件中的引用设置 `GO_OMNI_TRANSPORT=native` 可切换自研 HTTP/1.1,默认 nethttp。HTTPS 在文件的 `server.tls.certFile` / `server.tls.keyFile` 成对配置;实验 HTTP/2 选择 `http2` 且必须配置 TLS。不会自动读取未在文件声明的 TLS 环境变量。
{{if eq .Database "mysql"}}
`/health/ready` 已注册数据库探针:连接失败返回 `503`,数据库恢复后重新返回 `200`。
`/health/live` 始终只检查服务存活。`GO_OMNI_DB_PING_TIMEOUT` 限制初始化检查和数据库探针的等待时间。
退出时先排空 HTTP 请求,再关闭数据库连接池,最后关闭日志。
`/health/ready` 已注册数据库探针,连接失败返回 503,恢复后返回 200;`/health/live` 只检查进程存活。连接池参数直接写入 `database`,缺失字段保留 Default():
数据库连接池参数:
```text
GO_OMNI_DB_MAX_OPEN_CONNS=10
GO_OMNI_DB_MAX_IDLE_CONNS=5
GO_OMNI_DB_CONN_MAX_LIFETIME=30m
GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
GO_OMNI_DB_PING_TIMEOUT=5s
```yaml
database:
driver: mysql
dsn: "${GO_OMNI_DB_DSN}"
maxOpenConns: 10
maxIdleConns: 5
connMaxLifetime: 30m
connMaxIdleTime: 5m
pingTimeout: 5s
```
`MAX_IDLE_CONNS=0` 禁用空闲连接,`MAX_OPEN_CONNS=0` 表示不限制连接数。
项目会启用 MySQL `parseTime` 并按 UTC 读取 DATETIME 字段,JSON 字段保持 lowerCamelCase。
`maxIdleConns: 0` 禁用空闲连接;此项目要求 maxOpenConns 为正数。项目启用 MySQL parseTime,并按 UTC 读取 DATETIME;JSON 字段保持 lowerCamelCase。
事务内复用 Repository(在业务代码中导入 `database/sql` 和项目的 `internal/repository`):

View File

@@ -11,8 +11,8 @@ import (
)
type Config struct {
Server config.Config `json:"server"`
{{if eq .Database "mysql"}}Database mysqladapter.Config `json:"database" env:"GO_OMNI_DB"`{{end}}
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage" env:"GO_OMNI_STORAGE"`{{end}}
{{if eq .Database "mysql"}}Database mysqladapter.Config `json:"database"`{{end}}
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
}
func Default() Config {
server:=config.Default()
@@ -54,12 +54,12 @@ type StorageConfig struct {
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:"-" env:"GO_OMNI_FILES_TOKEN,secret"`
SigningKey string `json:"-" env:"GO_OMNI_STORAGE_SIGNING_KEY,secret"`
AccessKey string `json:"-" env:"GO_OMNI_STORAGE_ACCESS_KEY,secret"`
SecretKey string `json:"-" env:"GO_OMNI_STORAGE_SECRET_KEY,secret"`
SessionToken string `json:"-" env:"GO_OMNI_STORAGE_SESSION_TOKEN,secret"`
// File values may explicitly reference environment variables. Never log this struct.
FilesToken string `json:"filesToken"`
SigningKey string `json:"signingKey"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
SessionToken string `json:"sessionToken"`
}
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
func (s StorageConfig) Validate()error {
@@ -67,14 +67,14 @@ func (s StorageConfig) Validate()error {
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 not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("set GO_OMNI_FILES_TOKEN to a token of at least 32 bytes without whitespace")}{{end}}
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("storage.filesToken requires at least 32 bytes without whitespace")}{{end}}
{{if eq .Storage "local"}}
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")}
if len(s.SigningKey)<32 {return errors.New("storage.signingKey requires a stable secret of at least 32 bytes")}
{{else}}
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region and GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY are required")}
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region, accessKey and secretKey are required")}
{{if eq .Storage "r2"}}
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}

View File

@@ -1,13 +1,8 @@
package config
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
// Load is maintained by the generator. Extend Config/Default/Validate in
// config.go, or NewLoader in loader.go; do not duplicate the loading pipeline.
func Load(path string) (Config, error) {
loader,err:=NewLoader()
if err!=nil{return Config{},err}
return omniConfig.LoadProject(path,Default(),omniConfig.EnvOptions{
Prefix:"GO_OMNI_",Empty:"ignore",Aliases:omniConfig.ServerEnvAliases("GO_OMNI_","server"),
},loader)
// Load resolves explicit file references, binds onto Default and validates.
// Ordinary projects only edit Config, Default, Validate and the selected file.
func Load(path string)(Config,error){
loader,err:=NewLoader();if err!=nil{return Config{},err}
return omniConfig.LoadProject(path,Default(),loader)
}

View File

@@ -1,57 +0,0 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"strings"
"syscall"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/logx"
"{{.Module}}/internal/handler"
"{{.Module}}/internal/service"
"{{.Module}}/internal/servicecontext"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
settings, err := config.LoadFromEnv("")
if err != nil {
return err
}
if strings.TrimSpace(settings.Log.Service) == "" {
settings.Log.Service = "{{.Module}}"
}
logger, err := logx.New(settings.Log)
if err != nil {
return err
}
defer logger.Close()
// Access logs and business logs share the same configured sink.
logx.SetDefault(logger)
services, err := servicecontext.New(ctx, logger)
if err != nil { return err }
defer services.Close()
userService := service.NewUsers(services)
userHandler := handler.NewUsers(userService)
application := omni.New(omni.WithConfig(settings))
application.OnShutdown(logger.Close)
application.OnShutdown(services.Close)
application.GET("/health/live", services.Health.LivenessHandler())
application.GET("/health/ready", services.Health.ReadinessHandler())
userHandler.RegisterRoutes(application)
return application.RunContext(ctx, settings.Address)
}

View File

@@ -13,6 +13,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/logx"
"{{.Module}}/internal/model"
"{{.Module}}/internal/settings"
"{{.Module}}/internal/repository"
"{{.Module}}/internal/servicecontext"
)
@@ -23,15 +24,12 @@ func databaseContext(t *testing.T) *servicecontext.Context {
t.Helper()
dsn := os.Getenv("GO_OMNI_TEST_MYSQL_DSN")
if dsn == "" { t.Skip("set GO_OMNI_TEST_MYSQL_DSN to a dedicated test database") }
for _, key := range []string{"MAX_OPEN_CONNS", "MAX_IDLE_CONNS", "CONN_MAX_LIFETIME", "CONN_MAX_IDLE_TIME", "PING_TIMEOUT"} {
t.Setenv("GO_OMNI_DB_"+key, "")
}
t.Setenv("GO_OMNI_DB_DSN", dsn)
t.Setenv("GO_OMNI_DB_DRIVER", "mysql")
logger, err := logx.New(logx.Config{Writer: io.Discard})
if err != nil { t.Fatal(err) }
t.Cleanup(func() { _ = logger.Close() })
services, err := servicecontext.New(context.Background(), logger)
cfg,err:=settings.Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
services, err := servicecontext.New(context.Background(), logger,cfg)
if err != nil { t.Fatal(err) }
t.Cleanup(func() { _ = services.Close() })
return services

View File

@@ -2,6 +2,7 @@ package servicecontext
import (
"context"
"{{.Module}}/internal/settings"
{{if and .Modules (ne .Storage "none")}}"errors"
"net/http"
"sync"
@@ -38,16 +39,16 @@ type Context struct {
// New initializes the project's dependencies before the HTTP listener opens.
// The caller owns Logger; Close releases only resources created here.
func New(ctx context.Context, logger *logx.Logger) (*Context, error) {
func New(ctx context.Context, logger *logx.Logger, project settings.Config) (*Context, error) {
if ctx == nil { ctx = context.Background() }
if err := ctx.Err(); err != nil { return nil, err }
checker, err := health.New()
if err != nil { return nil, err }
services := &Context{Logger: logger, Health: checker}
{{if eq .Database "mysql"}}
cfg, err := database.ConfigFromEnv("")
if err != nil { return nil, err }
if cfg.Driver != "mysql" { return nil, fmt.Errorf("this project requires GO_OMNI_DB_DRIVER=mysql") }
cfg:=project.Database
if err:=cfg.Validate();err!=nil{return nil,err}
if cfg.Driver != "mysql" { return nil, fmt.Errorf("this project requires database.driver=mysql") }
dsn, err := mysql.ParseDSN(cfg.DSN)
if err != nil { return nil, fmt.Errorf("parse mysql connection settings: %w", err) }
if dsn.DBName == "" { return nil, fmt.Errorf("mysql connection settings require a database name") }

View File

@@ -2,7 +2,7 @@
本项目选用日志输出 `{{.LogOutput}}`、格式 `{{.LogFormat}}`、对象存储 `{{.Storage}}`。数据库选择 `{{.Database}}` 独立于对象存储。
配置入口为 `etc/config.yaml`,通过 `go run ./cmd/server -config /path/to/config.yaml` 指定其他 JSON/YAML 文件;`-config ''` 只用代码默认值和环境变量。优先级:默认值 → 文件 → 非空环境变量。未知字段、无效参数会在监听前拒绝。
配置入口为 `etc/config.yaml`,通过 `go run ./cmd/server -config /path/to/config.yaml` 指定其他 JSON/YAML/XML 文件;`-config ''` 只用代码默认值。文件用 `${NAME}` 或 `${NAME:-fallback}` 显式引用变量,固定值不受隐式环境覆盖;`-check-config` 只检查、不初始化依赖。未知字段、无效参数会在监听前拒绝。
```sh
go mod tidy
@@ -24,7 +24,7 @@ go run ./cmd/server
{{if eq .Database "mysql"}}还需按 README 配置独立 MySQL 数据库并应用 schema.sql;应用不自动迁移。{{end}}
默认仅监听 `127.0.0.1:8080`。`GO_OMNI_HTTP_ADDR` 覆盖地址;本地签名 URL 的外部地址通过 `GO_OMNI_STORAGE_PUBLIC_URL` 单独配置,始终以 `/storage/object` 结尾,不使用请求 Host 自动拼接可信链接。
默认仅监听 `127.0.0.1:8080`。文件中的 `${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}` 设置地址;本地签名 URL 的外部地址通过 `GO_OMNI_STORAGE_PUBLIC_URL` 单独配置,始终以 `/storage/object` 结尾,不使用请求 Host 自动拼接可信链接。
## 日志
@@ -35,7 +35,7 @@ curl -H 'Content-Type: application/json' -H 'X-Request-ID: example-request' \
-d '{"name":"Example","email":"example@example.com"}' http://127.0.0.1:8080/users
```
`GO_OMNI_LOG_LEVEL`、`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_FILE` 覆盖对应项;显式设置 `GO_OMNI_LOG_OUTPUT=stdout` 且未设置 LOG_FILE 时,切换到控制台并关闭文件轮转。慢请求阈值为 500ms,可通过 `GO_OMNI_SLOW_REQUEST_THRESHOLD` 调整。字段脱敏不扫描 msg/error 中的自由文本;不要把凭据或签名 URL 拼到日志中。
日志参数直接写在 `server.log`,例如 `level: debug`;也可显式写 `level: "${LOG_LEVEL:-info}"`。切换文件日志为控制台时,明确设置 `file: ""`、`output: stdout` 和 `rotation.enabled: false`,不会隐式重置其他字段。慢请求阈值使用 `server.slowRequestThreshold`,默认 500ms。字段脱敏不扫描 msg/error 自由文本;不要把凭据或签名 URL 拼到日志中。
{{if ne .Storage "none"}}## 文件示例
@@ -66,18 +66,18 @@ curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" -H 'Content-Type: applicati
响应包括随机 `data.key` 和 `data.request`(method/url/headers/expiresAt)。按返回的 method 和 headers 用原始文件字节上传到 URL;不能额外包 FormData。浏览器自动填写 Content-Length,其余签名 Header 原样发送;云桶需配置允许业务域名的 CORS。云端直传只绑定声明类型,实际内容需上传后验证;本地和 API 中转上传会检查允许类型及前缀嗅探结果。未完成内容验证前不要把暂存文件当作可信文件公开。
| 参数 | 默认值 / 环境变量 |
| 文件字段 | 默认值 / 含义 |
| --- | --- |
| 大小 | 32 MiB / GO_OMNI_STORAGE_MAX_BYTES |
| 类型 | text/plain、image/png、image/jpeg、application/pdf / GO_OMNI_STORAGE_CONTENT_TYPES(逗号分隔) |
| 链接有效期 | 5m / GO_OMNI_STORAGE_LINK_TTL(1s..1h) |
| 请求超时 | 30s / GO_OMNI_STORAGE_REQUEST_TIMEOUT(>0 且 <=5m) |
| 本地目录 | var/files / GO_OMNI_STORAGE_ROOT |
| 本地外部 URL | http://127.0.0.1:8080/storage/object / GO_OMNI_STORAGE_PUBLIC_URL |
| 云配置 | GO_OMNI_STORAGE_ENDPOINT、REGION、BUCKET、ACCOUNT_ID、SECURE |
| 环境密钥 | GO_OMNI_FILES_TOKEN、GO_OMNI_STORAGE_SIGNING_KEY、GO_OMNI_STORAGE_ACCESS_KEY、SECRET_KEY、SESSION_TOKEN |
| `storage.maxBytes` | 32 MiB |
| `storage.contentTypes` | text/plain、image/png、image/jpeg、application/pdf;文件数组 |
| `storage.linkTtl` | 5m,允许 1s..1h |
| `storage.requestTimeout` | 30s,大于 0 且不超过 5m |
| `storage.root` | var/files |
| `storage.publicUrl` | http://127.0.0.1:8080/storage/object |
| `storage.endpoint/region/bucket/accountId/secure` | 对应平台连接配置 |
| `storage.filesToken/signingKey/accessKey/secretKey/sessionToken` | 文件显式引用环境变量提供凭据 |
全部 JSON/YAML 字段使用 lowerCamelCase。密钥字段仅允许环境变量,配置文件中写入会报未知字段;不要把运行环境文件提交到仓库。S3 的 endpoint 为 host[:port],OSS 的可选 endpoint 为 HTTPS origin;R2 从 accountId 派生官方 endpoint,region 为 auto。仅 S3/MinIO 的本地测试可设置 SECURE=false。
全部 JSON/YAML/XML 字段使用 lowerCamelCase。文件可使用固定值或显式环境引用,不按字段名自动推导环境变量;凭据建议使用 `${NAME}`,不把含秘密的配置提交到仓库。S3 endpoint 为 host[:port],OSS 可选 endpoint 为 HTTPS origin;R2 从 accountId 派生官方 endpoint,region 为 auto。仅 S3/MinIO 本地测试可设置 `storage.secure: false`。
错误响应:未授权 401、无 Content-Length 411、超限 413、类型不允许 415、长度不符/非法 key 400、对象不存在 404、存储超时 504,其余内部错误统一隐藏细节。底层签名入口遵循存储协议返回 201/403 等状态,流式传输且访问日志保留实际状态。

View File

@@ -1,68 +1,30 @@
# Non-secret settings only. Environment variables override these values.
# Only fields listed here override Default(). Environment names are explicit.
server:
address: '127.0.0.1:8080'
enableLogger: true
slowRequestThreshold: 500ms
shutdownTimeout: 10s
{{if .API}} lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s
endpoints:
liveness:
enabled: true
path: /health/live
responseMode: app
readiness:
enabled: true
path: /health/ready
responseMode: app
openapi:
enabled: true
path: /openapi.json
{{end}}
log:
service: {{printf "%q" .Module}}
level: info
format: {{.LogFormat}}
{{if eq .LogOutput "file"}} file: './var/log/server.log'
rotation:
enabled: true
maxSizeMb: 100
maxBackups: 10
maxAgeDays: 7
compress: true
{{else}} output: {{.LogOutput}}
{{end}} redactKeys: []
# Optional cross-origin access: server.cors (disabled by default). See framework cors/README.md.
address: "${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}"
transport: "${GO_OMNI_TRANSPORT:-nethttp}"
{{if ne .Storage "none"}}storage:
provider: {{.Storage}}
maxBytes: 33554432
contentTypes: ['text/plain', 'image/png', 'image/jpeg', 'application/pdf']
linkTtl: 5m
requestTimeout: 30s
{{if eq .Storage "local"}} root: './var/files'
publicUrl: 'http://127.0.0.1:8080/storage/object'
{{else}} bucket: '' # set GO_OMNI_STORAGE_BUCKET
secure: true
{{if eq .Storage "r2"}} accountId: '' # set GO_OMNI_STORAGE_ACCOUNT_ID
region: auto
{{else if eq .Storage "s3"}} endpoint: '' # set GO_OMNI_STORAGE_ENDPOINT (host[:port])
region: us-east-1
{{else}} region: cn-hangzhou
endpoint: '' # empty uses the official regional endpoint
{{if not .API}} filesToken: "${GO_OMNI_FILES_TOKEN}"
{{end}}{{if eq .Storage "local"}} root: "${GO_OMNI_STORAGE_ROOT:-var/files}"
publicUrl: "${GO_OMNI_STORAGE_PUBLIC_URL:-http://127.0.0.1:8080/storage/object}"
signingKey: "${GO_OMNI_STORAGE_SIGNING_KEY}"
{{else}} bucket: "${GO_OMNI_STORAGE_BUCKET}"
accessKey: "${GO_OMNI_STORAGE_ACCESS_KEY}"
secretKey: "${GO_OMNI_STORAGE_SECRET_KEY}"
sessionToken: "${GO_OMNI_STORAGE_SESSION_TOKEN:-}"
{{if eq .Storage "r2"}} accountId: "${GO_OMNI_STORAGE_ACCOUNT_ID}"
{{else if eq .Storage "s3"}} endpoint: "${GO_OMNI_STORAGE_ENDPOINT}"
region: "${GO_OMNI_STORAGE_REGION:-us-east-1}"
{{else}} region: "${GO_OMNI_STORAGE_REGION:-cn-hangzhou}"
{{end}}{{end}}{{end}}
{{if and .API (eq .Database "mysql")}}database:
host: 127.0.0.1
port: 3306
database: '' # GO_OMNI_DB_DATABASE
user: '' # GO_OMNI_DB_USER; password via GO_OMNI_DB_PASSWORD
tls:
mode: required
pool:
maxOpenConns: 10
maxIdleConns: 5
connMaxLifetime: 30m
connMaxIdleTime: 5m
pingTimeout: 5s
host: "${GO_OMNI_DB_HOST:-127.0.0.1}"
port: "${GO_OMNI_DB_PORT:-3306}"
database: "${GO_OMNI_DB_DATABASE}"
user: "${GO_OMNI_DB_USER}"
password: "${GO_OMNI_DB_PASSWORD}"
{{end}}
{{if and (not .API) (eq .Database "mysql")}}database:
driver: mysql
dsn: "${GO_OMNI_DB_DSN}"
{{end}}

View File

@@ -18,8 +18,10 @@ import (
)
func main(){
path:=flag.String("config","etc/config.yaml","JSON/YAML settings; empty uses defaults and environment")
path:=flag.String("config","etc/config.yaml","configuration file; empty uses only Default()")
check:=flag.Bool("check-config",false,"validate without starting dependencies")
flag.Parse()
if *check {if _,err:=settings.Load(*path);err!=nil{log.Fatal(err)};return}
if err:=run(*path);err!=nil{log.Fatal(err)}
}
func run(path string)(runErr error){
@@ -31,7 +33,7 @@ func run(path string)(runErr error){
if err!=nil{return err}
defer func(){runErr=errors.Join(runErr,logger.Close())}()
logx.SetDefault(logger)
services,err:=servicecontext.New(ctx,logger)
services,err:=servicecontext.New(ctx,logger,cfg)
if err!=nil{return err}
defer func(){runErr=errors.Join(runErr,services.Close())}()
{{if ne .Storage "none"}}if err=services.InitStorage(ctx,cfg.Storage);err!=nil{return err}{{end}}

View File

@@ -3,21 +3,19 @@ package settings
import (
"errors"
"os"
"strings"
"time"
{{if ne .Storage "none"}}"fmt"
{{if ne .Storage "none"}}
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
"strconv"
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
{{if and .API (eq .Database "mysql")}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
{{if eq .Database "mysql"}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/logx"
{{if eq .LogOutput "file"}}"git.yuntuo.hk/k2safe/go-omni/logx"{{end}}
)
type Config struct {
Server config.Config `json:"server"`
{{if and .API (eq .Database "mysql")}}Database database.Config `json:"database"`{{end}}
{{if eq .Database "mysql"}}Database database.Config `json:"database"`{{end}}
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
}
@@ -31,7 +29,8 @@ func Default() Config {
{{if eq .LogOutput "file"}}server.Log.File="./var/log/server.log"
server.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:100,MaxBackups:10,MaxAgeDays:7,Compress:true}
{{else}}server.Log.Output={{printf "%q" .LogOutput}}{{end}}
return Config{Server:server{{if and .API (eq .Database "mysql")}},Database:database.DefaultConfig(){{end}}{{if ne .Storage "none"}},Storage:StorageConfig{
{{if eq .Database "mysql"}}db:=database.DefaultConfig();db.Driver="mysql"{{end}}
return Config{Server:server{{if eq .Database "mysql"}},Database:db{{end}}{{if ne .Storage "none"}},Storage:StorageConfig{
Provider:{{printf "%q" .Storage}}, 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,
@@ -41,27 +40,10 @@ func Default() Config {
}{{end}}}
}
// 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 ne .Storage "none"}}
if err=cfg.Storage.applyEnv();err!=nil{return Config{},err}
{{end}}
{{if and .API (eq .Database "mysql")}}cfg.Database,err=database.ApplyEnv(cfg.Database,"");if err!=nil{return Config{},err}{{end}}
if err=cfg.Validate();err!=nil{return Config{},err}
return cfg,nil
}
// Load uses Default(), the selected file's explicit references, and Validate.
func Load(path string)(Config,error){return config.LoadProject(path,Default())}
func (c Config) Validate()error {
{{if and .API (eq .Database "mysql")}}
{{if eq .Database "mysql"}}
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")}
{{end}}
@@ -85,45 +67,27 @@ type StorageConfig struct {
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:"-"`
// Use explicit environment references in the configuration file for secrets.
FilesToken string `json:"filesToken"`
SigningKey string `json:"signingKey"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
SessionToken string `json:"sessionToken"`
}
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!={{printf "%q" .Storage}} {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 not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("set GO_OMNI_FILES_TOKEN to a token of at least 32 bytes without whitespace")}{{end}}
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("storage.filesToken requires at least 32 bytes without whitespace")}{{end}}
{{if eq .Storage "local"}}
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")}
if len(s.SigningKey)<32 {return errors.New("storage.signingKey requires a stable secret of at least 32 bytes")}
{{else}}
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region and GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY are required")}
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region, accessKey and secretKey are required")}
{{if eq .Storage "r2"}}
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}

View File

@@ -21,18 +21,18 @@ func environment(t *testing.T){
}
func TestSettingsPrecedenceAndStrictness(t *testing.T){
environment(t)
{{if eq .Database "mysql"}}t.Setenv("GO_OMNI_DB_DSN","test:secret@tcp(localhost)/demo"){{end}}
source,err:=os.ReadFile("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
path:=filepath.Join(t.TempDir(),"config.yaml")
if err:=os.WriteFile(path,[]byte("server:\n address: '127.0.0.1:8123'\n log:\n level: debug\n"),0600);err!=nil{t.Fatal(err)}
source=[]byte(strings.Replace(string(source),"${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}","127.0.0.1:8123",1))
if err=os.WriteFile(path,source,0600);err!=nil{t.Fatal(err)}
t.Setenv("GO_OMNI_HTTP_ADDR","127.0.0.1:9123")
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout")
cfg,err:=Load(path);if err!=nil{t.Fatal(err)}
if cfg.Server.Address!="127.0.0.1:9123" || cfg.Server.Log.Level!="debug" || cfg.Server.Log.Output!="stdout" || cfg.Server.Log.File!="" || cfg.Server.Log.Rotation.Enabled {t.Fatal("configuration precedence failed")}
for _,body:=range []string{"unexpected: true","server:\n slowRequestThreshold: -1s","server:\n log:\n format: invalid"{{if ne .Storage "none"}},"storage:\n filesToken: forbidden","storage:\n maxBytes: -1","storage:\n provider: unsupported"{{end}}}{
if err:=os.WriteFile(path,[]byte(body),0600);err!=nil{t.Fatal(err)}
if _,err:=Load(path);err==nil{t.Fatal("invalid configuration accepted")}
initial:=Default()
if cfg.Server.Address!="127.0.0.1:8123"||cfg.Server.Log.Output!=initial.Server.Log.Output||cfg.Server.Log.File!=initial.Server.Log.File {t.Fatal("implicit overwrite")}
for _,body:=range []string{"unexpected: true","server:\n slowRequestThreshold: -1s","server:\n log:\n format: invalid"}{
if err=os.WriteFile(path,[]byte(body),0600);err!=nil{t.Fatal(err)}
if _,err=Load(path);err==nil{t.Fatal("invalid configuration accepted")}
}
{{if ne .Storage "none"}}
t.Setenv("GO_OMNI_FILES_TOKEN","")
if _,err:=Load("");err==nil{t.Fatal("missing file token accepted")}
{{end}}
}

View File

@@ -10,11 +10,11 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.20.0-preview"
const defaultFrameworkVersion = "v0.25.0-preview"
// This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published.
const modulesFrameworkVersion = "v0.7.0-preview"
const modulesFrameworkVersion = "v0.22.0-preview"
func installedFrameworkVersion() string {
info, _ := debug.ReadBuildInfo()

View File

@@ -1,66 +0,0 @@
package config
import (
"strings"
"testing"
"time"
)
func TestUnifiedEnvAliases(t *testing.T) {
type project struct {
Server Config `json:"server"`
Label string `json:"label"`
}
base := project{Server: Default(), Label: "base"}
for _, policy := range []string{"allow", "ignore", "reject"} {
t.Run(policy, func(t *testing.T) {
env := map[string]string{"TEST_HTTP_ADDR": "127.0.0.1:8099", "TEST_LOG_SERVICE": "", "TEST_SERVICE": "", "TEST_LABEL": ""}
opts := EnvOptions{Prefix: "TEST_", Empty: policy, Aliases: ServerEnvAliases("TEST_", "server"), Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
got, err := OverlayEnv(base, opts)
if policy == "reject" {
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatal(err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got.Server.Address != "127.0.0.1:8099" {
t.Fatal("alias ignored")
}
if policy == "allow" && got.Label != "" || policy == "ignore" && got.Label != "base" {
t.Fatal("empty policy differs")
}
env["TEST_SERVER_ADDRESS"] = "127.0.0.1:9001"
env["TEST_HTTP_READ_TIMEOUT"] = "3s"
env["TEST_ENABLE_LOGGER"] = "false"
env["TEST_TRUSTED_PROXIES"] = `["127.0.0.1"]`
got, err = OverlayEnv(base, opts)
if err != nil || got.Server.Address != "127.0.0.1:9001" || got.Server.HTTP.ReadTimeout != 3*time.Second || got.Server.EnableLogger || len(got.Server.TrustedProxies) != 1 {
t.Fatal(got, err)
}
env["TEST_SERVER_ADDRESS"] = ""
got, err = OverlayEnv(base, opts)
if err != nil {
t.Fatal(err)
}
expected := ""
if policy == "ignore" {
expected = base.Server.Address
}
if got.Server.Address != expected {
t.Fatal("empty canonical unexpectedly fell through to alias")
}
})
}
if base.Server.Address != ":8080" || base.Label != "base" {
t.Fatal("defaults mutated")
}
}
func TestAliasTypeErrorsDoNotExposeValue(t *testing.T) {
opts := EnvOptions{Prefix: "X_", Empty: "reject", Aliases: ServerEnvAliases("X_", ""), Lookup: func(k string) (string, bool) { return "private-invalid", k == "X_HTTP_READ_TIMEOUT" }}
if _, err := OverlayEnv(Default(), opts); err == nil || strings.Contains(err.Error(), "private-invalid") {
t.Fatal(err)
}
}

View File

@@ -59,6 +59,20 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
if err := b.step(path, depth); err != nil {
return err
}
if text, ok := v.(resolvedText); ok {
if dst.Kind() != reflect.Pointer && dst.Type() != durationType && dst.CanAddr() && !dst.Addr().Type().Implements(jsonUnmarshalerType) && !dst.Addr().Type().Implements(textType) {
switch dst.Kind() {
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
if err := parseTextValue(dst, string(text)); err != nil {
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
}
return nil
}
}
if dst.Kind() != reflect.Pointer {
v = string(text)
}
}
if v == nil {
return b.decode(nil, dst, path)
} // Preserve encoding/json null semantics.
@@ -78,7 +92,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
default:
return b.decode(v, dst, path)
}
if err := parseEnvValue(dst, strings.TrimSpace(raw)); err != nil {
if err := parseTextValue(dst, strings.TrimSpace(raw)); err != nil {
return b.doc.diagnostic(path, "expected a duration such as 5s", err)
}
return nil
@@ -102,7 +116,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
}
switch dst.Kind() {
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
if err := parseEnvValue(dst, strings.TrimSpace(string(text))); err != nil {
if err := parseTextValue(dst, strings.TrimSpace(string(text))); err != nil {
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
}
return nil
@@ -143,7 +157,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
}
}
if !ok {
return b.doc.diagnostic(child, "unknown field", nil)
return b.doc.diagnostic(child, unknownFieldReason(name, fields), nil)
}
if used[canonical] {
return b.doc.diagnostic(child, "duplicate field binding", nil)
@@ -237,6 +251,8 @@ func (b *documentBinder) dynamic(v any, path string, depth int) (any, error) {
switch x := v.(type) {
case Text:
return string(x), nil
case resolvedText:
return string(x), nil
case map[string]any:
out := make(map[string]any, len(x))
for _, k := range sortedKeys(x) {

View File

@@ -5,6 +5,7 @@ import (
"time"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/cors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
@@ -28,6 +29,7 @@ type TLSConfig struct {
// Config controls the default runtime behavior of an App.
type Config struct {
CORS cors.Config `json:"cors"`
Request contract.RequestPolicy `json:"request"`
Address string `json:"address"`
Transport string `json:"transport"`
@@ -61,6 +63,9 @@ func Default() Config {
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.CORS.Validate(); err != nil {
return AtField("cors", err)
}
if err := c.Lifecycle.Validate(); err != nil {
return AtField("lifecycle", err)
}
@@ -188,12 +193,14 @@ func WithTrustedProxies(proxies ...string) Option {
// WithConfig applies a complete configuration loaded from a file or
// environment. Later options can still override individual fields.
func WithConfig(settings Config) Option {
settings.CORS = settings.CORS.Clone()
settings.Response = settings.Response.Clone()
settings.Errors = settings.Errors.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return func(cfg *Config) {
*cfg = settings
cfg.CORS = settings.CORS.Clone()
cfg.Response = settings.Response.Clone()
cfg.Errors = settings.Errors.Clone()
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
@@ -201,6 +208,12 @@ func WithConfig(settings Config) Option {
}
}
// WithCORS installs an app-local CORS policy, before routing and authentication.
func WithCORS(settings cors.Config) Option {
settings = settings.Clone()
return func(cfg *Config) { cfg.CORS = settings.Clone() }
}
// WithResponse configures this application's default JSON envelope.
func WithResponse(settings response.Config) Option {
settings = settings.Clone()

View File

@@ -1,132 +1,6 @@
package config
import (
"strings"
"testing"
"time"
)
func TestLoadFromEnvUsesDefaults(t *testing.T) {
for _, key := range []string{
"GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
"GO_OMNI_TRUSTED_PROXIES",
"GO_OMNI_TRANSPORT", "GO_OMNI_TLS_CERT_FILE", "GO_OMNI_TLS_KEY_FILE",
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
"GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
"GO_OMNI_SLOW_REQUEST_THRESHOLD",
} {
t.Setenv(key, "")
}
cfg, err := LoadFromEnv("")
if err != nil {
t.Fatal(err)
}
if cfg.Address != ":8080" || cfg.Transport != TransportNetHTTP || cfg.TLS != (TLSConfig{}) || cfg.RequestIDHeader != "X-Request-ID" || cfg.ShutdownTimeout != 10*time.Second {
t.Fatalf("unexpected defaults: %+v", cfg)
}
if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" {
t.Fatalf("unexpected logger defaults: %+v", cfg.Log)
}
}
func TestTransportEnvironment(t *testing.T) {
for _, test := range []struct {
name, transport, cert, key, errorField string
}{
{"default", "", "", "", ""},
{"native", "native", "", "", ""},
{"nativeTLS", "native", "server.crt", "server.key", ""},
{"http2", "http2", "server.crt", "server.key", ""},
{"unknown", "typo", "", "", "transport"},
{"certOnly", "nethttp", "server.crt", "", "tls"},
{"keyOnly", "native", "", "server.key", "tls"},
{"http2WithoutTLS", "http2", "", "", "tls"},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("TRANSPORT_TEST_TRANSPORT", test.transport)
t.Setenv("TRANSPORT_TEST_TLS_CERT_FILE", test.cert)
t.Setenv("TRANSPORT_TEST_TLS_KEY_FILE", test.key)
cfg, err := LoadFromEnv("TRANSPORT_TEST_")
if test.errorField != "" {
if err == nil || !strings.Contains(err.Error(), test.errorField) {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
want := test.transport
if want == "" {
want = TransportNetHTTP
}
if cfg.Transport != want || cfg.TLS.CertFile != test.cert || cfg.TLS.KeyFile != test.key {
t.Fatalf("settings: %+v", cfg)
}
})
}
}
func TestLoadFromEnvOverridesSettings(t *testing.T) {
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "127.0.0.1, 10.0.0.0/8")
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
t.Setenv("GO_OMNI_LOG_OUTPUT", "stdout")
t.Setenv("GO_OMNI_SERVICE", "billing")
t.Setenv("GO_OMNI_ENVIRONMENT", "test")
t.Setenv("GO_OMNI_LOG_ADD_SOURCE", "true")
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "3s")
t.Setenv("GO_OMNI_SLOW_REQUEST_THRESHOLD", "500ms")
cfg, err := LoadFromEnv("")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger || len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[1] != "10.0.0.0/8" {
t.Fatalf("unexpected application settings: %+v", cfg)
}
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
t.Fatalf("unexpected logger settings: %+v", cfg.Log)
}
if cfg.SlowRequestThreshold != 500*time.Millisecond {
t.Fatal(cfg.SlowRequestThreshold)
}
}
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
t.Setenv("GO_OMNI_ENABLE_LOGGER", "sometimes")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "ENABLE_LOGGER") {
t.Fatalf("expected invalid logger flag error, got %v", err)
}
t.Setenv("GO_OMNI_ENABLE_LOGGER", "")
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "later")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "SHUTDOWN_TIMEOUT") {
t.Fatalf("expected invalid timeout error, got %v", err)
}
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "proxy.example.com")
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trustedProxies") {
t.Fatalf("expected invalid trusted proxy error, got %v", err)
}
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
cfg := Default()
cfg.Address = ""
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
t.Fatalf("expected invalid address error, got %v", err)
}
cfg = Default()
cfg.RequestIDHeader = " "
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "requestIdHeader") {
t.Fatalf("expected invalid request header error, got %v", err)
}
}
import "testing"
func TestWithConfigAndWithLogConfig(t *testing.T) {
settings := Default()
@@ -147,23 +21,3 @@ func TestWithConfigAndWithLogConfig(t *testing.T) {
t.Fatalf("log config was not replaced: %+v", cfg.Log)
}
}
func TestApplyEnvPreservesProjectDefaults(t *testing.T) {
cfg := Default()
cfg.Address = "127.0.0.1:8123"
cfg.Log.RedactKeys = []string{"bankAccount"}
cfg.TrustedProxies = []string{"127.0.0.1"}
t.Setenv("OVERLAY_TEST_LOG_LEVEL", "debug")
got, err := ApplyEnv(cfg, "OVERLAY_TEST_")
if err != nil {
t.Fatal(err)
}
if got.Address != cfg.Address || got.Log.Level != "debug" {
t.Fatal(got)
}
got.Log.RedactKeys[0] = "changed"
got.TrustedProxies[0] = "192.0.2.1"
if cfg.Log.RedactKeys[0] != "bankAccount" || cfg.TrustedProxies[0] != "127.0.0.1" {
t.Fatal("caller-owned defaults mutated")
}
}

51
config/cors_test.go Normal file
View File

@@ -0,0 +1,51 @@
package config_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/config"
)
type corsProject struct {
Server config.Config `json:"server"`
}
func (c corsProject) Validate() error { return config.AtField("server", c.Server.Validate()) }
func TestCORSConfiguration(t *testing.T) {
t.Setenv("WEB_ORIGIN", "https://web.example")
for ext, source := range map[string]string{
"yaml": "server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n allowedMethods: [GET, POST]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n",
"json": `{"server":{"cors":{"enabled":true,"allowedOrigins":["${WEB_ORIGIN}"],"allowedMethods":["GET","POST"],"allowedHeaders":["Authorization","Content-Type"],"maxAge":"10m"}}}`,
"xml": `<config><server><cors><enabled type="bool">true</enabled><allowedOrigins type="array"><item>${WEB_ORIGIN}</item></allowedOrigins><allowedMethods type="array"><item>GET</item><item>POST</item></allowedMethods><allowedHeaders type="array"><item>Authorization</item><item>Content-Type</item></allowedHeaders><maxAge>10m</maxAge></cors></server></config>`,
} {
t.Run(ext, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config."+ext)
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadProject(path, corsProject{Server: config.Default()})
if err != nil {
t.Fatal(err)
}
if !cfg.Server.CORS.Enabled || cfg.Server.CORS.AllowedOrigins[0] != "https://web.example" || cfg.Server.CORS.MaxAge != 10*time.Minute {
t.Fatal(cfg.Server.CORS)
}
})
}
path := filepath.Join(t.TempDir(), "bad.yaml")
if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n"), 0600); err != nil {
t.Fatal(err)
}
t.Setenv("WEB_ORIGIN", "https://user:secret@web.example")
_, err := config.LoadProject(path, corsProject{Server: config.Default()})
var d *config.Error
if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || d.Env != "WEB_ORIGIN" || d.Line != 5 || strings.Contains(err.Error(), "secret") {
t.Fatal(err)
}
}

View File

@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"strings"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// Error is a value-free diagnostic. Cause may contain private parser details;
@@ -56,6 +58,10 @@ func validationError(err error) *Error {
out.cause = err
return &out
}
var nested *configdiag.Error
if errors.As(err, &nested) {
return &Error{Field: nested.Field, Reason: nested.Reason, cause: err}
}
var c *ConfigError
if errors.As(err, &c) {
return &Error{Field: c.Field, Reason: c.Message, cause: err}
@@ -82,8 +88,9 @@ type Position struct{ Line, Column int }
// string, bool, json.Number, nil, []any, map[string]any. Text is available for
// formats such as XML whose scalar tokens have no inherent JSON type.
type Document struct {
Value any
Positions map[string]Position
Value any
Positions map[string]Position
environments map[string][]string
}
type Text string

View File

@@ -18,14 +18,15 @@ func TestEndpointConfiguration(t *testing.T) {
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
t.Fatal(cfg.Endpoints)
}
refs := configFile(t, "yaml", "endpoints:\n readiness:\n path: ${TEST_ENDPOINTS_READINESS_PATH}\n openapi:\n enabled: ${TEST_ENDPOINTS_OPENAPI_ENABLED}\n")
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
cfg, err = ApplyEnv(cfg, "TEST_")
cfg, err = LoadProject(refs, cfg)
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
t.Fatal(cfg.Endpoints, err)
}
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
if _, err = ApplyEnv(cfg, "TEST_"); err == nil {
if _, err = LoadProject(refs, cfg); err == nil {
t.Fatal("invalid bool accepted")
}
}

View File

@@ -1,83 +0,0 @@
package config
import (
"encoding/json"
"os"
"strings"
)
const defaultEnvPrefix = "GO_OMNI_"
// ServerEnvAliases returns the documented short names for Config fields.
// field is the JSON path of Config in a project (usually "server"), or ""
// for Config itself. Canonical names win over aliases. Both use the same
// EnvOptions.Empty/Lookup and typed parser; collections use JSON syntax.
func ServerEnvAliases(prefix, field string) map[string][]string {
root := prefix
if field != "" {
for _, part := range strings.Split(field, ".") {
root += envName(part) + "_"
}
}
names := map[string]string{
"ADDRESS": "HTTP_ADDR", "TRANSPORT": "TRANSPORT", "TLS_CERT_FILE": "TLS_CERT_FILE", "TLS_KEY_FILE": "TLS_KEY_FILE",
"REQUEST_ID_HEADER": "REQUEST_ID_HEADER", "ENABLE_LOGGER": "ENABLE_LOGGER", "SLOW_REQUEST_THRESHOLD": "SLOW_REQUEST_THRESHOLD",
"TRUSTED_PROXIES": "TRUSTED_PROXIES", "SHUTDOWN_TIMEOUT": "SHUTDOWN_TIMEOUT",
"LOG_LEVEL": "LOG_LEVEL", "LOG_FORMAT": "LOG_FORMAT", "LOG_OUTPUT": "LOG_OUTPUT", "LOG_FILE": "LOG_FILE", "LOG_SERVICE": "SERVICE", "LOG_ENVIRONMENT": "ENVIRONMENT", "LOG_ADD_SOURCE": "LOG_ADD_SOURCE",
"HTTP_READ_HEADER_TIMEOUT": "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT": "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT": "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT": "HTTP_IDLE_TIMEOUT", "HTTP_MAX_HEADER_BYTES": "HTTP_MAX_HEADER_BYTES",
"LIFECYCLE_STOP_TIMEOUT": "LIFECYCLE_STOP_TIMEOUT", "LIFECYCLE_DRAIN_TIMEOUT": "LIFECYCLE_DRAIN_TIMEOUT", "LIFECYCLE_CANCEL_TIMEOUT": "LIFECYCLE_CANCEL_TIMEOUT",
}
for _, endpoint := range []string{"LIVENESS", "READINESS", "OPENAPI"} {
for _, part := range []string{"ENABLED", "PATH", "RESPONSE_MODE"} {
if endpoint == "OPENAPI" && part == "RESPONSE_MODE" {
continue
}
name := "ENDPOINTS_" + endpoint + "_" + part
names[name] = name
}
}
aliases := map[string][]string{}
for key, alias := range names {
if root+key != prefix+alias {
aliases[root+key] = []string{prefix + alias}
}
}
return aliases
}
// LoadFromEnv loads defaults using the legacy short-name conventions.
func LoadFromEnv(prefix string) (Config, error) { return ApplyEnv(Default(), prefix) }
// ApplyEnv retains legacy whitespace trimming, ignored empty variables and
// comma-separated TRUSTED_PROXIES. New projects should use OverlayEnv with
// ServerEnvAliases for one explicit empty-value policy across the whole config.
func ApplyEnv(defaults Config, prefix string) (Config, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
prefix = defaultEnvPrefix
}
lookup := func(key string) (string, bool) {
raw, ok := os.LookupEnv(key)
raw = strings.TrimSpace(raw)
if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") {
values := strings.Split(raw, ",")
for i := range values {
values[i] = strings.TrimSpace(values[i])
}
data, _ := json.Marshal(values)
raw = string(data)
}
return raw, ok
}
defaults.Errors = defaults.Errors.Clone()
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")})
if err != nil {
return Config{}, err
}
if err = cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}

View File

@@ -21,7 +21,7 @@ func TestCatalogConfigFiles(t *testing.T) {
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "CATALOG_FIXTURE_")
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatal(err)
}
@@ -40,7 +40,7 @@ func TestCatalogConfigFiles(t *testing.T) {
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatal(err)
}
if _, err := config.LoadFile(path, "CATALOG_FIXTURE_"); err == nil {
if _, err := config.LoadFile(path); err == nil {
t.Fatal("accepted invalid catalog", body)
}
}
@@ -57,7 +57,7 @@ func TestCatalogReloadSnapshotIsolation(t *testing.T) {
t.Fatal(err)
}
defer logger.Close()
reloader, err := config.NewReloader(path, "CATALOG_RELOAD_", logger)
reloader, err := config.NewReloader(path, logger)
if err != nil {
t.Fatal(err)
}

View File

@@ -20,7 +20,7 @@ func TestTemplateConfigFiles(t *testing.T) {
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "TEMPLATE_TEST_")
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatal(err)
}

View File

@@ -4,103 +4,38 @@ import (
"context"
"errors"
"reflect"
"strings"
"sync"
"time"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func durations(v any, t reflect.Type) (any, error) {
if t == nil {
return v, nil
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == reflect.TypeOf(time.Duration(0)) {
if s, ok := v.(string); ok {
d, err := time.ParseDuration(s)
return int64(d), err
}
return v, nil
}
if t.Kind() == reflect.Struct {
if m, ok := v.(map[string]any); ok {
for n := 0; n < t.NumField(); n++ {
field := t.Field(n)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name == "-" {
continue
}
if name == "" {
name = field.Name
}
if item, exists := m[name]; exists {
converted, err := durations(item, field.Type)
if err != nil {
return nil, err
}
m[name] = converted
}
}
}
}
if t.Kind() == reflect.Map && t.Key().Kind() == reflect.String {
if values, ok := v.(map[string]any); ok {
for key, value := range values {
converted, err := durations(value, t.Elem())
if err != nil {
return nil, err
}
values[key] = converted
}
}
}
if t.Kind() == reflect.Slice {
if items, ok := v.([]any); ok {
for n, item := range items {
converted, err := durations(item, t.Elem())
if err != nil {
return nil, err
}
items[n] = converted
}
}
}
return v, nil
}
func LoadFile(path, prefix string) (Config, error) {
cfg, err := DecodeFile(path, Default())
if err != nil {
return Config{}, err
}
return ApplyEnv(cfg, prefix)
}
// LoadFile loads and validates framework configuration using explicit file references.
func LoadFile(path string) (Config, error) { return LoadProject(path, Default()) }
// Reloader only accepts log.level changes. Static changes and invalid files
// leave the current snapshot and logger untouched. Watch does not own a goroutine.
type Reloader struct {
mu sync.Mutex
path, prefix string
current Config
logger *logx.Logger
mu sync.Mutex
path string
current Config
logger *logx.Logger
}
func NewReloader(path, prefix string, logger *logx.Logger) (*Reloader, error) {
cfg, err := LoadFile(path, prefix)
func NewReloader(path string, logger *logx.Logger) (*Reloader, error) {
cfg, err := LoadFile(path)
if err != nil {
return nil, err
}
if err = logger.SetLevel(cfg.Log.Level); err != nil {
return nil, err
}
return &Reloader{path: path, prefix: prefix, current: cfg, logger: logger}, nil
return &Reloader{path: path, current: cfg, logger: logger}, nil
}
func (r *Reloader) Reload() error {
r.mu.Lock()
defer r.mu.Unlock()
next, err := LoadFile(r.path, r.prefix)
next, err := LoadFile(r.path)
if err != nil {
return err
}

View File

@@ -20,8 +20,8 @@ func TestFileMergeAndSafeReload(t *testing.T) {
}
write("address: ':8123'\nshutdownTimeout: 7s\nlog:\n level: info\n")
t.Setenv("TEST_MODULE_HTTP_ADDR", ":9123")
cfg, err := LoadFile(path, "TEST_MODULE_")
if err != nil || cfg.Address != ":9123" || cfg.ShutdownTimeout != 7*time.Second {
cfg, err := LoadFile(path)
if err != nil || cfg.Address != ":8123" || cfg.ShutdownTimeout != 7*time.Second {
t.Fatal(cfg, err)
}
var output bytes.Buffer
@@ -33,7 +33,7 @@ func TestFileMergeAndSafeReload(t *testing.T) {
}
defer l.Close()
child := l.With("scope", "test")
r, err := NewReloader(path, "TEST_MODULE_", l)
r, err := NewReloader(path, l)
if err != nil {
t.Fatal(err)
}

View File

@@ -22,7 +22,7 @@ func TestHTTPFileAndEnvironment(t *testing.T) {
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "HTTP_TEST_")
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatal(err)
}
@@ -35,7 +35,10 @@ func TestHTTPFileAndEnvironment(t *testing.T) {
t.Setenv("HTTP_TEST_HTTP_WRITE_TIMEOUT", "8s")
t.Setenv("HTTP_TEST_HTTP_IDLE_TIMEOUT", "9s")
t.Setenv("HTTP_TEST_HTTP_MAX_HEADER_BYTES", "16384")
got, err := config.ApplyEnv(cfg, "HTTP_TEST_")
if err := os.WriteFile(path, []byte(`{"http":{"readHeaderTimeout":"${HTTP_TEST_HTTP_READ_HEADER_TIMEOUT}","readTimeout":"${HTTP_TEST_HTTP_READ_TIMEOUT}","writeTimeout":"${HTTP_TEST_HTTP_WRITE_TIMEOUT}","idleTimeout":"${HTTP_TEST_HTTP_IDLE_TIMEOUT}","maxHeaderBytes":"${HTTP_TEST_HTTP_MAX_HEADER_BYTES}"}}`), 0600); err != nil {
t.Fatal(err)
}
got, err := config.LoadProject(path, cfg)
if err != nil {
t.Fatal(err)
}
@@ -55,7 +58,12 @@ func TestHTTPInvalidAndUnsupportedSettings(t *testing.T) {
value = "-1s"
}
t.Setenv("HTTP_INVALID_HTTP_"+key, value)
if _, err := config.LoadFromEnv("HTTP_INVALID_"); err == nil {
names := map[string]string{"READ_HEADER_TIMEOUT": "readHeaderTimeout", "READ_TIMEOUT": "readTimeout", "WRITE_TIMEOUT": "writeTimeout", "IDLE_TIMEOUT": "idleTimeout", "MAX_HEADER_BYTES": "maxHeaderBytes"}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"http":{"`+names[key]+`":"${HTTP_INVALID_HTTP_`+key+`}"}}`), 0600); err != nil {
t.Fatal(err)
}
if _, err := config.LoadFile(path); err == nil {
t.Fatal("invalid HTTP value accepted")
}
})

View File

@@ -14,7 +14,7 @@ func TestLifecycleConfiguration(t *testing.T) {
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: 2s\n drainTimeout: 3s\n cancelTimeout: 4s\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "PHASE_TEST_")
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatal(err)
}
@@ -24,13 +24,16 @@ func TestLifecycleConfiguration(t *testing.T) {
for _, key := range []string{"STOP_TIMEOUT", "DRAIN_TIMEOUT", "CANCEL_TIMEOUT"} {
t.Setenv("PHASE_TEST_LIFECYCLE_"+key, "6s")
}
cfg, err = config.ApplyEnv(cfg, "PHASE_TEST_")
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: ${PHASE_TEST_LIFECYCLE_STOP_TIMEOUT}\n drainTimeout: ${PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT}\n cancelTimeout: ${PHASE_TEST_LIFECYCLE_CANCEL_TIMEOUT}\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err = config.LoadProject(path, cfg)
if err != nil || cfg.Lifecycle.StopTimeout != 6*time.Second || cfg.Lifecycle.DrainTimeout != 6*time.Second || cfg.Lifecycle.CancelTimeout != 6*time.Second {
t.Fatal(cfg.Lifecycle, err)
}
for _, v := range []string{"bad", "-1s"} {
t.Setenv("PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT", v)
if _, err := config.ApplyEnv(cfg, "PHASE_TEST_"); err == nil {
if _, err := config.LoadProject(path, cfg); err == nil {
t.Fatal("invalid duration accepted")
}
}

View File

@@ -26,6 +26,7 @@ func (f DecoderFunc) Decode(data []byte) (Document, error) { return f(data) }
type Loader struct {
decoders map[string]Decoder
sealed bool
lookup func(string) (string, bool)
}
type LoaderOption func(*Loader) error
@@ -55,7 +56,7 @@ func WithDecoder(extension string, decoder Decoder) LoaderOption {
}
func NewLoader(options ...LoaderOption) (*Loader, error) {
l := &Loader{decoders: map[string]Decoder{"json": JSONDecoder{}, "yaml": YAMLDecoder{}, "yml": YAMLDecoder{}, "xml": XMLDecoder{}}}
l := &Loader{lookup: os.LookupEnv, decoders: map[string]Decoder{"json": JSONDecoder{}, "yaml": YAMLDecoder{}, "yml": YAMLDecoder{}, "xml": XMLDecoder{}}}
for _, o := range options {
if o == nil {
return nil, errors.New("config: nil loader option")
@@ -135,7 +136,7 @@ func (l *Loader) read(path string) (doc Document, err error) {
if _, ok := doc.Value.(map[string]any); !ok {
return doc, doc.diagnostic("", "root must be an object", nil)
}
return doc, nil
return resolveDocument(doc, l.lookup)
}
func decodeSafely(decoder Decoder, data []byte) (doc Document, err error) {
defer func() {
@@ -165,11 +166,12 @@ func DecodeFile[T any](path string, defaults T, loaders ...*Loader) (T, error) {
if err != nil {
return zero, err
}
if err := rejectEnvironmentTags(reflect.TypeOf(defaults), map[reflect.Type]bool{}); err != nil {
return zero, err
}
result, err := bindDocument(doc, defaults)
if err != nil {
d := validationError(err)
d.File = path
return zero, d
return zero, locateError(err, path, doc)
}
return result, nil
}

View File

@@ -16,7 +16,7 @@ import (
type loaderExample struct {
Server Config `json:"server"`
Upload struct {
MaxBytes int64 `json:"maxBytes" envDefault:"1024"`
MaxBytes int64 `json:"maxBytes"`
} `json:"upload"`
Values []int64 `json:"values"`
Optional *string `json:"optional"`
@@ -45,19 +45,19 @@ func configFile(t *testing.T, ext, raw string) string {
func TestUnifiedFormatAndPrecedence(t *testing.T) {
formats := map[string]string{
"json": `{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":7},"values":[9007199254740993],"optional":null,"enabled":true,"dynamic":{"id":9007199254740993}}`,
"yaml": "server:\n http:\n readTimeout: 2s\nupload:\n maxBytes: 7\nvalues: [9007199254740993]\noptional: null\nenabled: true\ndynamic:\n id: 9007199254740993\n",
"xml": `<config><server><http><readTimeout>2s</readTimeout></http></server><upload><maxBytes>7</maxBytes></upload><values type="array"><item>9007199254740993</item></values><optional type="null"/><enabled>true</enabled><dynamic><id type="number">9007199254740993</id></dynamic></config>`,
"json": `{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":"${APP_UPLOAD_MAX_BYTES}"},"values":[9007199254740993],"optional":null,"enabled":"${APP_ENABLED}","dynamic":{"id":9007199254740993}}`,
"yaml": "server:\n http:\n readTimeout: 2s\nupload:\n maxBytes: \"${APP_UPLOAD_MAX_BYTES}\"\nvalues: [9007199254740993]\noptional: null\nenabled: \"${APP_ENABLED}\"\ndynamic:\n id: 9007199254740993\n",
"xml": `<config><server><http><readTimeout>2s</readTimeout></http></server><upload><maxBytes>${APP_UPLOAD_MAX_BYTES}</maxBytes></upload><values type="array"><item>9007199254740993</item></values><optional type="null"/><enabled>${APP_ENABLED}</enabled><dynamic><id type="number">9007199254740993</id></dynamic></config>`,
}
var reference loaderExample
for _, ext := range []string{"json", "yaml", "xml"} {
t.Run(ext, func(t *testing.T) {
old := "old"
base := loaderExample{Server: Default(), Optional: &old}
cfg, err := LoadProject(configFile(t, ext, formats[ext]), base, EnvOptions{Prefix: "APP_", Lookup: func(key string) (string, bool) {
cfg, err := LoadProject(configFile(t, ext, formats[ext]), base, testLoader(func(key string) (string, bool) {
v, ok := map[string]string{"APP_UPLOAD_MAX_BYTES": "0", "APP_ENABLED": "false"}[key]
return v, ok
}})
}))
if err != nil {
t.Fatal(err)
}
@@ -77,8 +77,8 @@ func TestUnifiedFormatAndPrecedence(t *testing.T) {
}
})
}
cfg, err := LoadProject("", loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
if err != nil || cfg.Upload.MaxBytes != 1024 {
cfg, err := LoadProject("", loaderExample{Server: Default()}, testLoader(noEnvironment))
if err != nil || cfg.Upload.MaxBytes != 0 {
t.Fatal(cfg, err)
}
}
@@ -97,7 +97,7 @@ func TestConfigDiagnosticsAndCauses(t *testing.T) {
} {
t.Run(tc.ext+tc.field, func(t *testing.T) {
path := configFile(t, tc.ext, tc.body)
_, err := LoadProject(path, loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
_, err := LoadProject(path, loaderExample{Server: Default()}, testLoader(noEnvironment))
var d *Error
if !errors.As(err, &d) || d.File != path || d.Field != tc.field || d.Reason == "" || tc.line && d.Line < 1 {
t.Fatalf("missing location: %#v %v", d, err)
@@ -108,7 +108,7 @@ func TestConfigDiagnosticsAndCauses(t *testing.T) {
})
}
path := filepath.Join(t.TempDir(), "missing.json")
_, err := LoadProject(path, loaderExample{}, EnvOptions{})
_, err := LoadProject(path, loaderExample{})
if !errors.Is(err, os.ErrNotExist) {
t.Fatal("I/O cause discarded", err)
}
@@ -119,11 +119,12 @@ func TestConfigDiagnosticsAndCauses(t *testing.T) {
if !errors.As(err, &typed) {
t.Fatal("type cause discarded", err)
}
_, err = LoadProject(configFile(t, "yaml", "upload:\n maxBytes: 7"), loaderExample{Server: Default()}, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { return "-1", k == "APP_UPLOAD_MAX_BYTES" }})
_, err = LoadProject(configFile(t, "yaml", "upload:\n maxBytes: \"${APP_UPLOAD_MAX_BYTES}\""), loaderExample{Server: Default()}, testLoader(func(k string) (string, bool) { return "-1", k == "APP_UPLOAD_MAX_BYTES" }))
var d *Error
if !errors.As(err, &d) || d.Env != "APP_UPLOAD_MAX_BYTES" || d.Field != "upload.maxBytes" || d.File != "" || d.Line != 0 {
t.Fatal("environment validation wrongly blamed file", err)
if !errors.As(err, &d) || d.Env != "APP_UPLOAD_MAX_BYTES" || d.Field != "upload.maxBytes" || d.File == "" || d.Line != 2 {
t.Fatal("missing file and variable provenance", err)
}
}
func TestMalformedFormatsAndLimits(t *testing.T) {
@@ -134,7 +135,7 @@ func TestMalformedFormatsAndLimits(t *testing.T) {
} {
for i, raw := range bodies {
t.Run(fmt.Sprintf("%s%d", ext, i), func(t *testing.T) {
_, err := LoadProject(configFile(t, ext, raw), loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment})
_, err := LoadProject(configFile(t, ext, raw), loaderExample{Server: Default()}, testLoader(noEnvironment))
if err == nil || strings.Contains(err.Error(), "private-value") {
t.Fatal(err)
}
@@ -163,7 +164,7 @@ func TestDecoderRegistryAndConcurrentUse(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
cfg, e := LoadProject(path, loaderExample{Server: Default()}, EnvOptions{Lookup: noEnvironment}, l)
cfg, e := LoadProject(path, loaderExample{Server: Default()}, l)
if e != nil || cfg.Upload.MaxBytes != 42 {
t.Error(e)
}
@@ -191,25 +192,6 @@ func TestDecoderRegistryAndConcurrentUse(t *testing.T) {
}
}
func TestUnifiedLoggerNormalization(t *testing.T) {
type project struct {
Server Config `json:"server"`
}
base := project{Server: Default()}
base.Server.Log.File = "old.log"
base.Server.Log.Rotation.Enabled = true
for _, full := range []bool{false, true} {
name := "GO_OMNI_LOG_OUTPUT"
if full {
name = "GO_OMNI_SERVER_LOG_OUTPUT"
}
cfg, err := LoadProject("", base, EnvOptions{Prefix: "GO_OMNI_", Empty: "ignore", Aliases: ServerEnvAliases("GO_OMNI_", "server"), Lookup: func(k string) (string, bool) { return "stdout", k == name }})
if err != nil || cfg.Server.Log.File != "" || cfg.Server.Log.Rotation.Enabled || base.Server.Log.File != "old.log" {
t.Fatal(cfg, err)
}
}
}
func FuzzConfigurationDecoders(f *testing.F) {
f.Add(uint8(0), []byte(`{"count":1}`))
f.Add(uint8(1), []byte("count: 1"))
@@ -332,18 +314,21 @@ func TestConfigDefaultsIsolationAndEmptyValues(t *testing.T) {
func TestDefaultStageDoesNotReadEnvironment(t *testing.T) {
reads := 0
type C struct {
Value int `json:"value" envDefault:"3"`
Value int `json:"value"`
}
got, err := LoadProject("", C{}, EnvOptions{Lookup: func(string) (string, bool) { reads++; return "", false }})
if err != nil || got.Value != 3 || reads != 1 {
got, err := LoadProject("", C{Value: 3}, testLoader(func(string) (string, bool) { reads++; return "9", true }))
if err != nil || got.Value != 3 || reads != 0 {
t.Fatal(got, err, reads)
}
d := locateError(Invalid("servers[0].timeout", "must be positive"), "a.json", Document{}, map[string]string{"servers": "APP_SERVERS"})
if d.Env != "APP_SERVERS" || d.File != "" {
t.Fatal(d)
}
}
func testLoader(lookup func(string) (string, bool)) *Loader {
l, err := NewLoader(WithLookupEnv(lookup))
if err != nil {
panic(err)
}
return l
}
func TestDecoderRegistryFrozen(t *testing.T) {
l, err := NewLoader()
if err != nil {
@@ -358,17 +343,12 @@ func BenchmarkConfigurationStartup(b *testing.B) {
if err := os.WriteFile(path, []byte(`{"server":{"http":{"readTimeout":"2s"}},"upload":{"maxBytes":4096}}`), 0600); err != nil {
b.Fatal(err)
}
options := EnvOptions{Prefix: "BENCH_", Lookup: func(name string) (string, bool) {
if name == "BENCH_UPLOAD_MAX_BYTES" {
return "8192", true
}
return "", false
}}
defaults := loaderExample{Server: Default()}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := LoadProject(path, defaults, options); err != nil {
if _, err := LoadProject(path, defaults); err != nil {
b.Fatal(err)
}
}

View File

@@ -0,0 +1,85 @@
package config_test
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/config"
mysql "git.yuntuo.hk/k2safe/go-omni/database/mysql"
"git.yuntuo.hk/k2safe/go-omni/httpc"
)
type moduleConfig struct {
Database mysql.Config `json:"database"`
Server config.Config `json:"server"`
Client httpc.Config `json:"client"`
}
func (c moduleConfig) Validate() error {
if err := c.Database.Validate(); err != nil {
return config.AtField("database", err)
}
if err := c.Server.Validate(); err != nil {
return config.AtField("server", err)
}
return config.AtField("client", c.Client.Validate())
}
func TestModuleFieldDiagnostics(t *testing.T) {
for _, tc := range []struct{ name, source, field, reason string }{
{"port", "database:\n port: 70000\n", "database.port", "1 and 65535"},
{"host", "database:\n host: 'invalid host'\n", "database.host", "hostname"},
{"pool", "database:\n pool:\n maxIdleConns: 999\n", "database.pool.maxIdleConns", "maxOpenConns"},
{"ping", "database:\n pool:\n pingTimeout: 0s\n", "database.pool.pingTimeout", "positive"},
{"timeout", "database:\n readTimeout: -1s\n", "database.readTimeout", "nonnegative"},
{"mysqlZone", "database:\n location: Secret/Not_A_Zone\n", "database.location", "IANA"},
{"tlsPair", "database:\n tls:\n certFile: secret-file\n keyFile: ''\n", "database.tls.keyFile", "certFile"},
{"level", "server:\n log:\n level: secret-level\n", "server.log.level", "debug"},
{"format", "server:\n log:\n format: secret-format\n", "server.log.format", "json or text"},
{"rotation", "server:\n log:\n rotation:\n maxBackups: -1\n", "server.log.rotation.maxBackups", "10000"},
{"zone", "server:\n log:\n timeZone: Secret/Not_A_Zone\n", "server.log.timeZone", "IANA"},
{"lifecycle", "server:\n lifecycle:\n stopTimeout: -1s\n", "server.lifecycle.stopTimeout", "nonnegative"},
{"policy", "server:\n request:\n body: secret-mode\n", "server.request.body", "required"},
{"clientPool", "client:\n maxIdleConns: -1\n", "client.maxIdleConns", "131072"},
{"proxy", "client:\n proxy:\n mode: url\n url: 'https://private:secret@localhost/path'\n", "client.proxy.url", "origin"},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(tc.source), 0600); err != nil {
t.Fatal(err)
}
_, err := config.LoadProject(path, moduleConfig{Database: mysql.DefaultConfig(), Server: config.Default()})
var d *config.Error
if !errors.As(err, &d) || d.Field != tc.field || d.File != path || d.Line <= 1 || d.Column <= 0 || !strings.Contains(d.Reason, tc.reason) {
t.Fatalf("diagnostic: %v", err)
}
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "Secret/") || strings.Contains(err.Error(), "private") {
t.Fatalf("value leaked: %v", err)
}
})
}
}
func TestModuleDiagnosticsKeepReferenceAndCause(t *testing.T) {
t.Setenv("CHECK_PORT", "70000")
path := filepath.Join(t.TempDir(), "config.yaml")
os.WriteFile(path, []byte("database:\n port: '${CHECK_PORT}'\n"), 0600)
_, err := config.LoadProject(path, moduleConfig{Database: mysql.DefaultConfig(), Server: config.Default()})
var d *config.Error
if !errors.As(err, &d) || d.Field != "database.port" || d.Env != "CHECK_PORT" || d.Line != 2 {
t.Fatal(err)
}
original := mysql.DefaultConfig()
original.Port = 70000
cause := original.Validate()
wrapped := config.AtField("database", cause)
if !errors.Is(wrapped, cause) {
t.Fatal("lost original validation error")
}
// Unknown project errors continue to be redacted rather than echoed.
wrapped = config.AtField("custom", errors.New("private-password"))
if strings.Contains(wrapped.Error(), "private-password") {
t.Fatal(wrapped)
}
}

View File

@@ -1,318 +1,105 @@
package config
import (
"bytes"
"encoding"
"encoding/json"
"errors"
"fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"math"
"os"
"reflect"
"strconv"
"strings"
"time"
"unicode"
)
// EnvOptions controls typed project configuration overlays. Explicit env tags
// are absolute names; otherwise JSON names become PREFIX_NESTED_FIELD names.
// Empty is allow (default), ignore or reject. No input values appear in errors.
type EnvOptions struct {
Prefix string
Empty string
Lookup func(string) (string, bool)
// Aliases maps canonical absolute environment names to ordered fallback names.
// A present canonical value wins, including an explicit empty value.
Aliases map[string][]string
}
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
func ApplyDefaults[T any](base T) (T, error) { return projectOverlay(base, EnvOptions{}, true) }
// LoadProject applies code/tag defaults, a strict optional file, environment,
// then the project's Validate method, before any logger or dependency is opened.
// envDefault is only applied to zero-valued defaults before reading the file.
func LoadProject[T any](path string, defaults T, options EnvOptions, loaders ...*Loader) (T, error) {
// LoadProject clones defaults, resolves only explicit file references, binds the
// provided fields and validates. Empty path uses only defaults. No env tags,
// prefixes, aliases or implicit environment overlays are applied.
func LoadProject[T any](path string, defaults T, loaders ...*Loader) (T, error) {
var zero T
loader, err := selectLoader(loaders)
if err != nil {
return zero, err
}
cfg, err := projectOverlay(defaults, options, true)
if err != nil {
if err = rejectEnvironmentTags(reflect.TypeOf(defaults), map[reflect.Type]bool{}); err != nil {
return zero, err
}
var doc Document
doc := Document{Value: map[string]any{}}
if path != "" {
doc, err = loader.read(path)
if err != nil {
return zero, err
}
cfg, err = bindDocument(doc, cfg)
if err != nil {
return zero, locateError(err, path, doc, nil)
}
} else {
value, e := cloneConfiguration(reflect.ValueOf(&cfg).Elem(), 0)
if e != nil {
return zero, &Error{Reason: "cannot clone defaults", cause: e}
}
cfg = value.Interface().(T)
}
origins := map[string]string{}
cfg, err = projectOverlay(cfg, options, false, origins)
cfg, err := bindDocument(doc, defaults)
if err != nil {
return zero, err
return zero, locateError(err, path, doc)
}
normalizeProject(reflect.ValueOf(&cfg).Elem(), "", origins, 0)
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
if err = valid.Validate(); err != nil {
return zero, locateError(err, path, doc, origins)
return zero, locateError(err, path, doc)
}
} else if valid, ok := any(cfg).(interface{ Validate() error }); ok {
if err = valid.Validate(); err != nil {
return zero, locateError(err, path, doc)
}
}
return cfg, nil
}
func locateError(err error, path string, doc Document, origins map[string]string) *Error {
func locateError(err error, path string, doc Document) *Error {
d := validationError(err)
field := d.Field
for field != "" && origins[field] == "" {
index := strings.LastIndexAny(field, ".[")
if index < 0 {
field = ""
} else {
field = field[:index]
}
}
if env := origins[field]; env != "" {
d.Env = env
d.File = ""
d.Line = 0
d.Column = 0
return d
}
d.File = path
if d.Line == 0 {
p := doc.Positions[d.Field]
d.Line = p.Line
d.Column = p.Column
}
for field := d.Field; field != ""; {
if names := doc.environments[field]; len(names) > 0 {
d.Env = strings.Join(names, ", ")
break
}
i := strings.LastIndexAny(field, ".[")
if i < 0 {
break
}
field = field[:i]
}
return d
}
// Framework-owned normalization stays inside the common pipeline. Explicit
// environment stream selection clears a file profile unless a file override
// was also provided; project code never needs to repeat this rule.
func normalizeProject(v reflect.Value, path string, origins map[string]string, depth int) {
if depth > 32 {
return
// Reject removed declarations rather than silently ignoring deployment rules.
func rejectEnvironmentTags(t reflect.Type, seen map[reflect.Type]bool) error {
if t == nil || seen[t] {
return nil
}
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
seen[t] = true
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
return rejectEnvironmentTags(t.Elem(), seen)
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" {
continue
}
_, env := f.Tag.Lookup("env")
_, def := f.Tag.Lookup("envDefault")
if env || def {
return &Error{Field: f.Name, Reason: "env/envDefault tags were removed; use Default() and explicit file references; see CONFIG.md"}
}
if f.Tag.Get("json") != "-" {
if err := rejectEnvironmentTags(f.Type, seen); err != nil {
return err
}
}
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
if v.Type() == reflect.TypeOf(Config{}) && v.CanAddr() {
c := v.Addr().Interface().(*Config)
if origins[joinField(path, "log.output")] != "" && c.Log.Output != "" && (origins[joinField(path, "log.file")] == "" || c.Log.File == "") {
c.Log.File = ""
c.Log.Rotation = logx.RotationConfig{}
}
return
}
for i := 0; i < v.NumField(); i++ {
f := v.Type().Field(i)
if f.PkgPath != "" {
continue
}
name := strings.Split(f.Tag.Get("json"), ",")[0]
if name == "-" {
continue
}
if name == "" {
name = f.Name
}
normalizeProject(v.Field(i), joinField(path, name), origins, depth+1)
}
}
// OverlayEnv overlays the entire project struct without mutating the input.
// Tags: env:"CUSTOM_NAME,required,secret,empty=reject"; env:"-" excludes a
// subtree. required means nonempty string/non-nil pointer or collection; false
// and zero are valid scalars. Use project Validate for semantic constraints.
// Slices/maps use JSON syntax, avoiding ambiguous comma and empty semantics.
func OverlayEnv[T any](base T, options EnvOptions) (T, error) {
return projectOverlay(base, options, false)
return nil
}
var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
var durationType = reflect.TypeOf(time.Duration(0))
func projectOverlay[T any](base T, options EnvOptions, defaults bool, sourceMaps ...map[string]string) (T, error) {
var zero T
if options.Lookup == nil {
options.Lookup = os.LookupEnv
}
if options.Empty == "" {
options.Empty = "allow"
}
if options.Empty != "allow" && options.Empty != "ignore" && options.Empty != "reject" {
return zero, errors.New("config: empty policy must be allow, ignore or reject")
}
v := reflect.ValueOf(&base).Elem()
if v.Kind() != reflect.Struct {
return zero, errors.New("config: project configuration must be a struct")
}
result := reflect.New(v.Type()).Elem()
result.Set(v)
seen := map[string]bool{}
var walk func(reflect.Value, string, string, int) error
walk = func(value reflect.Value, prefix, path string, depth int) error {
if depth > 32 {
return errors.New("config: nesting exceeds 32")
}
for i := 0; i < value.NumField(); i++ {
field := value.Type().Field(i)
dst := value.Field(i)
if !dst.CanSet() || field.PkgPath != "" {
continue
}
tag := strings.Split(field.Tag.Get("env"), ",")
if tag[0] == "-" {
continue
}
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
if jsonName == "-" && tag[0] == "" {
continue
}
if jsonName == "" {
jsonName = field.Name
}
fieldPath := joinField(path, jsonName)
key := prefix + envName(jsonName)
if tag[0] != "" {
key = tag[0]
}
empty, required := options.Empty, false
for _, opt := range tag[1:] {
switch opt {
case "required":
required = true
case "secret", "":
case "empty=allow":
empty = "allow"
case "empty=ignore":
empty = "ignore"
case "empty=reject":
empty = "reject"
default:
return fmt.Errorf("config: invalid env tag for %s", key)
}
}
if dst.Kind() == reflect.Struct && !dst.Addr().Type().Implements(textType) {
if err := walk(dst, key+"_", fieldPath, depth+1); err != nil {
return err
}
continue
}
if dst.Kind() == reflect.Pointer && dst.Type().Elem().Kind() == reflect.Struct && !dst.Type().Implements(textType) {
// Clone before traversing so a failed overlay never edits defaults.
child := reflect.New(dst.Type().Elem())
if !dst.IsNil() {
child.Elem().Set(dst.Elem())
}
if err := walk(child.Elem(), key+"_", fieldPath, depth+1); err != nil {
return err
}
if !dst.IsNil() || !child.Elem().IsZero() {
dst.Set(child)
}
if required && !defaults && dst.IsNil() {
return &Error{Env: key, Field: fieldPath, Reason: "is required"}
}
continue
}
// Unexported runtime capabilities are skipped unless explicitly tagged.
if dst.Kind() == reflect.Func || dst.Kind() == reflect.Chan || dst.Kind() == reflect.Interface {
if tag[0] != "" {
return fmt.Errorf("config: %s has unsupported environment type", key)
}
continue
}
if seen[key] {
return fmt.Errorf("config: duplicate environment key %s", key)
}
seen[key] = true
var raw string
var present bool
if !defaults {
raw, present = options.Lookup(key)
}
selectedKey := key
if !present && !defaults {
for _, alias := range options.Aliases[key] {
if raw, present = options.Lookup(alias); present {
selectedKey = alias
break
}
}
}
if defaults {
raw, present = field.Tag.Lookup("envDefault")
present = present && dst.IsZero()
}
if present && raw == "" && !defaults {
if empty == "reject" {
return &Error{Env: selectedKey, Field: fieldPath, Reason: "must not be empty"}
}
if empty == "ignore" {
present = false
}
}
if present {
parsed := reflect.New(dst.Type()).Elem()
if err := parseEnvValue(parsed, raw); err != nil {
origin := selectedKey
if defaults {
origin = ""
}
return &Error{Env: origin, Field: fieldPath, Reason: "expected a valid " + dst.Type().String(), cause: err}
}
dst.Set(parsed)
if !defaults && len(sourceMaps) > 0 {
sourceMaps[0][fieldPath] = selectedKey
}
}
if required && !defaults && emptyRequired(dst) {
return &Error{Env: key, Field: fieldPath, Reason: "is required"}
}
}
return nil
}
if err := walk(result, options.Prefix, "", 0); err != nil {
return zero, err
}
return result.Interface().(T), nil
}
func emptyRequired(v reflect.Value) bool {
switch v.Kind() {
case reflect.String:
return v.Len() == 0
case reflect.Pointer, reflect.Map, reflect.Slice:
return v.IsNil()
}
return false
}
func parseEnvValue(v reflect.Value, raw string) (err error) {
func parseTextValue(v reflect.Value, raw string) (err error) {
defer func() {
if recover() != nil {
err = errors.New("invalid custom value")
@@ -320,7 +107,7 @@ func parseEnvValue(v reflect.Value, raw string) (err error) {
}()
if v.Kind() == reflect.Pointer {
v.Set(reflect.New(v.Type().Elem()))
return parseEnvValue(v.Elem(), raw)
return parseTextValue(v.Elem(), raw)
}
if v.CanAddr() && v.Addr().Type().Implements(textType) {
return v.Addr().Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw))
@@ -363,37 +150,6 @@ func parseEnvValue(v reflect.Value, raw string) (err error) {
v.SetFloat(x)
}
return err
case reflect.Slice, reflect.Map:
var value any
if err := apierrors.DecodeCatalogJSON([]byte(raw), &value); err != nil {
return err
}
value, err := durations(value, v.Type())
if err != nil {
return err
}
encoded, err := json.Marshal(value)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(encoded))
decoder.DisallowUnknownFields()
return decoder.Decode(v.Addr().Interface())
}
return errors.New("unsupported environment value")
}
func envName(s string) string {
r := []rune(s)
var out strings.Builder
for i, c := range r {
if i > 0 && unicode.IsUpper(c) && (unicode.IsLower(r[i-1]) || unicode.IsDigit(r[i-1]) || (i+1 < len(r) && unicode.IsLower(r[i+1]))) {
out.WriteByte('_')
}
if c == '-' {
c = '_'
}
out.WriteRune(unicode.ToUpper(c))
}
return out.String()
return errors.New("expected scalar text")
}

View File

@@ -42,20 +42,3 @@ func TestConfigFilePreservesDefaultsWithoutSharingMutableData(t *testing.T) {
t.Fatal("decode error leaked contents", err)
}
}
func TestEnvCollectionPrecisionAndDurationMaps(t *testing.T) {
type cfg struct {
IDs []int64 `json:"ids"`
Timers map[string]time.Duration `json:"timers"`
}
lookup := func(key string) (string, bool) {
v, ok := map[string]string{"IDS": "[9007199254740993]", "TIMERS": `{"backoff":"250ms"}`}[key]
return v, ok
}
c, err := OverlayEnv(cfg{}, EnvOptions{Lookup: lookup})
if err != nil || c.IDs[0] != 9007199254740993 || c.Timers["backoff"] != 250*time.Millisecond {
t.Fatal(c, err)
}
if _, err = OverlayEnv(cfg{}, EnvOptions{Lookup: func(key string) (string, bool) { return `{"x":"1s","x":"2s"}`, key == "TIMERS" }}); err == nil {
t.Fatal("duplicate map key accepted")
}
}

View File

@@ -1,124 +0,0 @@
package config
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
type projectNested struct {
ReadTimeout time.Duration `json:"readTimeout"`
Names []string `json:"names"`
}
type projectConfig struct {
Nested *projectNested `json:"nested"`
Enabled bool `json:"enabled"`
Count int `json:"count" envDefault:"12"`
Password string `json:"-" env:"ETAX_PASSWORD,secret,required,empty=reject"`
Label string `json:"label" env:",empty=ignore"`
}
func (c projectConfig) Validate() error {
if c.Count < 0 {
return errors.New("count must be nonnegative")
}
return nil
}
func TestProjectConfigurationOrderAndValues(t *testing.T) {
f := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(f, []byte("count: 3\nenabled: true\nnested:\n readTimeout: 2s\n names: [file]\nlabel: keep\n"), 0600); err != nil {
t.Fatal(err)
}
env := map[string]string{"ETAX_PASSWORD": " password ", "APP_ENABLED": "false", "APP_COUNT": "0", "APP_NESTED_READ_TIMEOUT": "7s", "APP_NESTED_NAMES": "[]", "APP_LABEL": ""}
options := EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
cfg, err := LoadProject(f, projectConfig{}, options)
if err != nil {
t.Fatal(err)
}
if cfg.Enabled || cfg.Count != 0 || cfg.Password != " password " || cfg.Nested.ReadTimeout != 7*time.Second || cfg.Nested.Names == nil || len(cfg.Nested.Names) != 0 || cfg.Label != "keep" {
t.Fatal("overlay mismatch")
}
delete(env, "APP_COUNT")
cfg, err = LoadProject("", projectConfig{}, options)
if err != nil || cfg.Count != 12 {
t.Fatal(cfg.Count, err)
}
env["APP_COUNT"] = "-1"
if _, err = LoadProject(f, projectConfig{}, options); err == nil {
t.Fatal("project Validate not called")
}
}
func TestProjectOverlayIsTransactionalAndRedacted(t *testing.T) {
base := projectConfig{Nested: &projectNested{ReadTimeout: time.Second, Names: []string{"old"}}, Password: "original"}
env := map[string]string{"APP_NESTED_READ_TIMEOUT": "2s", "APP_NESTED_NAMES": "[\"new\"]", "APP_COUNT": "secret-value"}
_, err := OverlayEnv(base, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }})
if err == nil || strings.Contains(err.Error(), "secret-value") {
t.Fatal(err)
}
if base.Nested.ReadTimeout != time.Second || !reflect.DeepEqual(base.Nested.Names, []string{"old"}) {
t.Fatal("default pointer modified")
}
delete(env, "APP_COUNT")
env["ETAX_PASSWORD"] = ""
if _, err = OverlayEnv(base, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}); err == nil {
t.Fatal("empty secret accepted")
}
type required struct {
Token string `env:"TOKEN,required"`
}
if _, err = OverlayEnv(required{}, EnvOptions{Lookup: func(string) (string, bool) { return "", false }}); err == nil {
t.Fatal("required absent")
}
type duplicate struct {
A string `env:"SAME"`
B string `env:"SAME"`
}
if _, err = OverlayEnv(duplicate{}, EnvOptions{}); err == nil {
t.Fatal("duplicate env binding accepted")
}
}
func TestEnvironmentCollectionAndEmptySemantics(t *testing.T) {
type sample struct {
Values []time.Duration
Flag bool
Text string
Optional *int
}
cases := []struct {
raw string
valid bool
}{{`["1s","2s"]`, true}, {`["invalid-secret"]`, false}}
for _, tc := range cases {
cfg, err := OverlayEnv(sample{}, EnvOptions{Lookup: func(k string) (string, bool) {
if k == "VALUES" {
return tc.raw, true
}
return "", false
}})
if (err == nil) != tc.valid {
t.Fatal(err)
}
if tc.valid && !reflect.DeepEqual(cfg.Values, []time.Duration{time.Second, 2 * time.Second}) {
t.Fatal(cfg)
}
if err != nil && strings.Contains(err.Error(), "secret") {
t.Fatal(err)
}
}
for _, mode := range []string{"allow", "ignore", "reject"} {
cfg, err := OverlayEnv(sample{Text: "old"}, EnvOptions{Empty: mode, Lookup: func(k string) (string, bool) { return "", k == "TEXT" }})
if mode == "reject" {
if err == nil {
t.Fatal("empty not rejected")
}
} else if err != nil || (mode == "allow" && cfg.Text != "") || (mode == "ignore" && cfg.Text != "old") {
t.Fatal(cfg, err)
}
}
}

210
config/references.go Normal file
View File

@@ -0,0 +1,210 @@
package config
import (
"errors"
"fmt"
"strings"
"unicode/utf8"
)
// WithLookupEnv supplies an explicit-reference lookup for tests or an embedding
// host. Ordinary projects need no loader options. The function must support
// concurrent loads; each load caches each referenced name independently.
func WithLookupEnv(lookup func(string) (string, bool)) LoaderOption {
return func(l *Loader) error {
if l == nil || l.sealed || lookup == nil {
return errors.New("config: expected a non-nil environment lookup on a new loader")
}
l.lookup = lookup
return nil
}
}
// resolvedText is textual input bound according to the target scalar type. It
// never becomes an object, collection or null by interpreting environment data.
type resolvedText string
const maxExpandedBytes = 4 << 20
type environmentValue struct {
value string
present bool
}
type referenceResolver struct {
lookup func(string) (string, bool)
cache map[string]environmentValue
doc Document
nodes, bytes, cacheBytes int
}
func resolveDocument(doc Document, lookup func(string) (string, bool)) (Document, error) {
r := referenceResolver{lookup: lookup, cache: map[string]environmentValue{}, doc: Document{Positions: doc.Positions, environments: map[string][]string{}}}
v, err := r.walk(doc.Value, "", 0)
if err != nil {
return Document{}, err
}
r.doc.Value = v
return r.doc, nil
}
func (r *referenceResolver) walk(v any, path string, depth int) (any, error) {
r.nodes++
if depth > maxConfigDepth || r.nodes > maxConfigNodes {
return nil, r.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
}
switch x := v.(type) {
case string:
return r.text(x, path, false)
case Text:
return r.text(string(x), path, true)
case map[string]any:
out := make(map[string]any, len(x))
for _, key := range sortedKeys(x) {
value, err := r.walk(x[key], joinField(path, key), depth+1)
if err != nil {
return nil, err
}
out[key] = value
}
return out, nil
case []any:
out := make([]any, len(x))
for i, value := range x {
var err error
out[i], err = r.walk(value, fmt.Sprintf("%s[%d]", path, i), depth+1)
if err != nil {
return nil, err
}
}
return out, nil
default:
return v, nil
}
}
func (r *referenceResolver) fail(path, name, reason string) error {
d := r.doc.diagnostic(path, reason, nil)
d.Env = name
return d
}
func validEnvironmentName(name string) bool {
if len(name) == 0 {
return false
}
for i := 0; i < len(name); i++ {
c := name[i]
if c == '_' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || i > 0 && c >= '0' && c <= '9' {
continue
}
return false
}
return true
}
func (r *referenceResolver) lookupValue(name, path string) (environmentValue, error) {
if v, ok := r.cache[name]; ok {
return v, nil
}
value, present := r.lookup(name)
if len(value) > maxConfigBytes || r.cacheBytes+len(value) > maxExpandedBytes {
return environmentValue{}, r.fail(path, name, "environment value exceeds configuration size limit")
}
if !utf8.ValidString(value) {
return environmentValue{}, r.fail(path, name, "environment value is not valid UTF-8")
}
r.cacheBytes += len(value)
v := environmentValue{value, present}
r.cache[name] = v
return v, nil
}
func (r *referenceResolver) text(raw, path string, xmlText bool) (any, error) {
var out strings.Builder
var names []string
seen := map[string]bool{}
if !strings.Contains(raw, "$") {
if len(raw) > maxConfigBytes || r.bytes+len(raw) > maxExpandedBytes {
return nil, r.fail(path, "", "expanded configuration exceeds size limit")
}
r.bytes += len(raw)
if xmlText {
return Text(raw), nil
}
return raw, nil
}
appendText := func(s string) error {
if out.Len()+len(s) > maxConfigBytes || r.bytes+len(s) > maxExpandedBytes {
return r.fail(path, strings.Join(names, ", "), "expanded configuration exceeds size limit")
}
out.WriteString(s)
r.bytes += len(s)
return nil
}
// Scan only the file's decoded scalar. Inserted environment values are never
// scanned again, including dollars, quotes, separators or newline characters.
for i := 0; i < len(raw); {
j := strings.IndexByte(raw[i:], '$')
if j < 0 {
if err := appendText(raw[i:]); err != nil {
return nil, err
}
break
}
j += i
if err := appendText(raw[i:j]); err != nil {
return nil, err
}
switch {
case j+1 < len(raw) && raw[j+1] == '$':
if err := appendText("$"); err != nil {
return nil, err
}
i = j + 2
case j+1 < len(raw) && raw[j+1] == '{':
end := strings.IndexByte(raw[j+2:], '}')
if end < 0 {
return nil, r.fail(path, "", "unclosed environment reference; use ${NAME}, ${NAME:-fallback} or $${...} for a literal")
}
end += j + 2
expression := raw[j+2 : end]
name, fallback, hasDefault := strings.Cut(expression, ":-")
if !validEnvironmentName(name) || strings.Contains(fallback, "${") {
return nil, r.fail(path, "", "invalid environment reference; use ${NAME} or ${NAME:-fallback}; nested references are not supported")
}
r.nodes++
if r.nodes > maxConfigNodes {
return nil, r.fail(path, name, "environment reference count exceeds configuration limit")
}
if !seen[name] {
names = append(names, name)
seen[name] = true
}
value, err := r.lookupValue(name, path)
if err != nil {
return nil, err
}
selected := value.value
if !value.present || selected == "" {
if !hasDefault {
return nil, r.fail(path, name, "required environment variable is unset or empty")
}
selected = fallback
}
if err := appendText(selected); err != nil {
return nil, err
}
i = end + 1
default:
if err := appendText("$"); err != nil {
return nil, err
}
i = j + 1
}
}
if len(names) > 0 {
r.doc.environments[path] = names
return resolvedText(out.String()), nil
}
if xmlText {
return Text(out.String()), nil
}
return out.String(), nil
}

230
config/references_test.go Normal file
View File

@@ -0,0 +1,230 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
)
type referenceConfig struct {
Port int `json:"port"`
BatchSize int `json:"batchSize"`
Enabled bool `json:"enabled"`
Password string `json:"password"`
Address string `json:"address"`
Timeout time.Duration `json:"timeout"`
Optional *int `json:"optional"`
Labels map[string]string `json:"labels"`
Numbers []int64 `json:"numbers"`
Dynamic any `json:"dynamic"`
}
func (c referenceConfig) Validate() error {
if c.Port < 0 {
return Invalid("port", "must not be negative")
}
return nil
}
func TestExplicitReferencesAcrossFormats(t *testing.T) {
// Delimiters, quotes and newlines belong to the credential, never file syntax.
secret := " DDSFLKJlsf:saf:-123\"\n${DO_NOT_READ}&<>\\ "
sources := map[string]string{
"json": `{"port":"${PORT:-3306}","enabled":"${ENABLED:-true}","password":"${PASSWORD}","address":"https://${HOST}:${PORT:-3306}/v1","timeout":"${TIMEOUT:-3s}","optional":"${ZERO}","numbers":["${BIG}"],"dynamic":"${ZERO}","labels":{"literal":"$${NOT_READ}","empty":"${OPTIONAL:-}","dollars":"$$cost"}}`,
"yaml": "port: \"${PORT:-3306}\"\nenabled: \"${ENABLED:-true}\"\npassword: \"${PASSWORD}\"\naddress: \"https://${HOST}:${PORT:-3306}/v1\"\ntimeout: \"${TIMEOUT:-3s}\"\noptional: \"${ZERO}\"\nnumbers: [\"${BIG}\"]\ndynamic: \"${ZERO}\"\nlabels:\n literal: \"$${NOT_READ}\"\n empty: \"${OPTIONAL:-}\"\n dollars: \"$$cost\"\n",
"xml": `<config><port>${PORT:-3306}</port><enabled>${ENABLED:-true}</enabled><password>${PASSWORD}</password><address>https://${HOST}:${PORT:-3306}/v1</address><timeout>${TIMEOUT:-3s}</timeout><optional>${ZERO}</optional><numbers type="array"><item>${BIG}</item></numbers><dynamic>${ZERO}</dynamic><labels><literal>$${NOT_READ}</literal><empty>${OPTIONAL:-}</empty><dollars>$$cost</dollars></labels></config>`,
}
for ext, source := range sources {
t.Run(ext, func(t *testing.T) {
calls := map[string]int{}
values := map[string]string{"PORT": "0", "ENABLED": "false", "PASSWORD": secret, "HOST": "example.test", "ZERO": "0", "BIG": "9007199254740993"}
l := testLoader(func(name string) (string, bool) { calls[name]++; v, ok := values[name]; return v, ok })
got, err := LoadProject(configFile(t, ext, source), referenceConfig{Port: 77, BatchSize: 100, Enabled: true, Password: "old"}, l)
if err != nil {
t.Fatal(err)
}
if got.Port != 0 || got.BatchSize != 100 || got.Enabled || got.Password != secret || got.Address != "https://example.test:0/v1" || got.Timeout != 3*time.Second || got.Optional == nil || *got.Optional != 0 || got.Numbers[0] != 9007199254740993 || got.Dynamic != "0" {
t.Fatal("binding changed values")
}
if got.Labels["literal"] != "${NOT_READ}" || got.Labels["empty"] != "" || got.Labels["dollars"] != "$cost" {
t.Fatal(got.Labels)
}
for name, n := range calls {
if n != 1 {
t.Fatal("not cached", name, n)
}
}
if calls["DO_NOT_READ"] != 0 || calls["NOT_READ"] != 0 {
t.Fatal("recursive expansion")
}
})
}
}
func TestReferenceFallbackAndFixedValues(t *testing.T) {
for _, present := range []bool{false, true} {
l := testLoader(func(string) (string, bool) { return "", present })
got, err := LoadProject(configFile(t, "yaml", "port: \"${PORT:-3306}\"\npassword: \"${PASSWORD:-DDSFLKJlsf:saf:-123}\""), referenceConfig{}, l)
if err != nil || got.Port != 3306 || got.Password != "DDSFLKJlsf:saf:-123" {
t.Fatal(got, err)
}
for _, ext := range []string{"json", "yaml", "xml"} {
raw := `{"password":"${PASSWORD}"}`
if ext == "yaml" {
raw = "password: '${PASSWORD}'"
}
if ext == "xml" {
raw = "<config><password>${PASSWORD}</password></config>"
}
path := configFile(t, ext, raw)
_, err = LoadProject(path, referenceConfig{Password: "initial"}, l)
var d *Error
if !errors.As(err, &d) || d.Env != "PASSWORD" || d.File != path || d.Field != "password" || d.Line < 1 {
t.Fatal("missing required-reference diagnostic", err)
}
}
}
calls := 0
l := testLoader(func(string) (string, bool) { calls++; return "999", true })
got, err := LoadProject(configFile(t, "json", `{"port":0,"enabled":false,"password":""}`), referenceConfig{Port: 77, BatchSize: 100, Enabled: true, Password: "old"}, l)
if err != nil || got.Port != 0 || got.BatchSize != 100 || got.Enabled || got.Password != "" || calls != 0 {
t.Fatal(got, err, calls)
}
got, err = LoadProject(configFile(t, "yaml", "password: '${VALUE}'"), referenceConfig{}, testLoader(func(string) (string, bool) { return " ", true }))
if err != nil || got.Password != " " {
t.Fatal("trimmed credential", err)
}
}
func TestReferenceDiagnosticsAndScalarLimits(t *testing.T) {
for _, value := range []string{"private-not-int", "92233720368547758080", "false", "null", "-1"} {
path := configFile(t, "yaml", "port: '${PORT}'")
_, err := LoadProject(path, referenceConfig{}, testLoader(func(string) (string, bool) { return value, true }))
var d *Error
if !errors.As(err, &d) || d.File != path || d.Env != "PORT" || d.Field != "port" || d.Line != 1 || d.Column < 1 || strings.Contains(err.Error(), value) {
t.Fatalf("invalid diagnostic: %v", err)
}
}
for _, raw := range []string{"${BAD-123}", "${}", "${X", "${1BAD}", "${BAD NAME}", "${X:-${Y}}"} {
encoded, _ := json.Marshal(map[string]string{"password": raw})
if _, err := LoadProject(configFile(t, "json", string(encoded)), referenceConfig{}, testLoader(noEnvironment)); err == nil {
t.Fatal("invalid syntax accepted", raw)
}
}
for _, value := range []string{string([]byte{0xff}), strings.Repeat("x", maxConfigBytes+1)} {
if _, err := LoadProject(configFile(t, "yaml", "password: '${SECRET}'"), referenceConfig{}, testLoader(func(string) (string, bool) { return value, true })); err == nil {
t.Fatal("unbounded/invalid environment")
}
}
path := configFile(t, "yaml", "labels:\n a: '${BIG}'\n b: '${BIG}'\n c: '${BIG}'\n d: '${BIG}'\n e: '${BIG}'")
if _, err := LoadProject(path, referenceConfig{}, testLoader(func(string) (string, bool) { return strings.Repeat("x", maxConfigBytes), true })); err == nil {
t.Fatal("expansion budget bypassed")
}
// An environment value cannot inject an array/object or coerce dynamic null.
for _, raw := range []string{"[1,2]", "{\"x\":1}", "null"} {
_, err := LoadProject(configFile(t, "yaml", "numbers: '${VALUE}'"), referenceConfig{}, testLoader(func(string) (string, bool) { return raw, true }))
if err == nil {
t.Fatal("structure injected")
}
}
}
func TestReferenceCacheIsolationAndRemovedRules(t *testing.T) {
path := configFile(t, "yaml", "password: '${TOKEN}'")
for i := 0; i < 24; i++ {
t.Run(fmt.Sprint(i), func(t *testing.T) {
t.Parallel()
v := fmt.Sprint(i)
l := testLoader(func(string) (string, bool) { return v, true })
for n := 0; n < 4; n++ {
got, err := LoadProject(path, referenceConfig{}, l)
if err != nil || got.Password != v {
t.Fatal("load leaked", err)
}
}
})
}
}
func TestReferenceLookupPerLoadAndConcurrentLoader(t *testing.T) {
var mu sync.Mutex
reads := 0
l := testLoader(func(string) (string, bool) { mu.Lock(); defer mu.Unlock(); reads++; return "secret", true })
path := configFile(t, "yaml", "password: '${TOKEN}${TOKEN}'")
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
got, err := LoadProject(path, referenceConfig{}, l)
if err != nil || got.Password != "secretsecret" {
t.Error(err)
}
}()
}
wg.Wait()
if reads != 24 {
t.Fatal(reads)
}
}
func TestRemovedEnvTagsAndFieldSuggestion(t *testing.T) {
type old struct {
Value int `json:"value" envDefault:"9"`
}
if _, err := LoadProject("", old{}); err == nil || !strings.Contains(err.Error(), "removed") {
t.Fatal(err)
}
type oldSecret struct {
Token string `json:"-" env:"TOKEN"`
}
if _, err := DecodeFile(configFile(t, "json", `{}`), oldSecret{}); err == nil {
t.Fatal("old env tag ignored")
}
_, err := LoadProject(configFile(t, "yaml", "batchSiz: 10"), referenceConfig{})
if err == nil || !strings.Contains(err.Error(), `did you mean "batchSize"`) {
t.Fatal(err)
}
t.Setenv("GO_OMNI_BATCH_SIZE", "999")
got, err := LoadProject("", referenceConfig{BatchSize: 100})
if err != nil || got.BatchSize != 100 {
t.Fatal("implicit overlay", err)
}
}
func FuzzConfigurationReferences(f *testing.F) {
for _, s := range []string{"${X}", "$${X}", "${X:-DDS:abc:-123}", "${X:-}", "https://${X}/${X}", "${X:-${Y}}"} {
f.Add(s, " secret${NO}\n\" ")
}
f.Fuzz(func(t *testing.T, raw, value string) {
if len(raw) > 8192 || len(value) > 8192 {
return
}
doc := Document{Value: map[string]any{"password": raw}}
resolved, err := resolveDocument(doc, func(string) (string, bool) { return value, true })
if err != nil {
return
}
_, _ = bindDocument(resolved, referenceConfig{})
})
}
func BenchmarkExplicitConfiguration(b *testing.B) {
for _, refs := range []bool{false, true} {
b.Run(fmt.Sprint(refs), func(b *testing.B) {
source := `{"port":3306,"enabled":false,"password":"fixed","batchSize":0}`
if refs {
source = `{"port":"${PORT:-3306}","enabled":"${ENABLED:-false}","password":"${PASSWORD}","batchSize":0}`
}
path := b.TempDir() + "/config.json"
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
b.Fatal(err)
}
l := testLoader(func(k string) (string, bool) { return "private", k == "PASSWORD" })
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := LoadProject(path, referenceConfig{BatchSize: 100}, l); err != nil {
b.Fatal(err)
}
}
})
}
}

51
config/suggestions.go Normal file
View File

@@ -0,0 +1,51 @@
package config
import (
"reflect"
"strconv"
"strings"
)
// Suggest only a unique, nearby declared field. Input values are never used.
func unknownFieldReason(name string, fields map[string]reflect.StructField) string {
if len(name) < 3 || len(name) > 128 {
return "unknown field"
}
best, match, tied := 3, "", false
for candidate := range fields {
if len(candidate) > 128 {
continue
}
d := fieldDistance(strings.ToLower(name), strings.ToLower(candidate))
if d < best {
best, match, tied = d, candidate, false
} else if d == best {
tied = true
}
}
if match != "" && !tied && best <= 2 {
return "unknown field; did you mean " + strconv.Quote(match) + "?"
}
return "unknown field"
}
func fieldDistance(a, b string) int {
prev := make([]int, len(b)+1)
for i := range prev {
prev[i] = i
}
for i := 0; i < len(a); i++ {
left, diagonal := i+1, prev[0]
prev[0] = left
for j := 0; j < len(b); j++ {
cost := 0
if a[i] != b[j] {
cost = 1
}
old := prev[j+1]
left = min(left+1, old+1, diagonal+cost)
prev[j+1] = left
diagonal = old
}
}
return prev[len(b)]
}

View File

@@ -3,9 +3,11 @@ package contract
import (
"context"
"errors"
"fmt"
"io"
"strings"
"unicode/utf8"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// RequestPolicy describes protocol strictness, independently of business
@@ -35,7 +37,7 @@ func (p RequestPolicy) Validate() error {
ok = ok || value == item.value
}
if !ok {
return fmt.Errorf("request policy: invalid %s", item.name)
return configdiag.New(item.name, "must be one of "+strings.Join(item.allowed, ", "))
}
}
return nil

192
copyx/check.go Normal file
View File

@@ -0,0 +1,192 @@
package copyx
import (
"errors"
"fmt"
"reflect"
"sort"
)
// ErrDynamicType means a source interface requires a concrete runtime value.
// An exact converter can explicitly own that dynamic boundary.
var ErrDynamicType = errors.New("copyx: dynamic source type requires runtime validation or an exact converter")
// Check validates a source/target type pair using the same options as Copy.
// It never executes converters and requires no example values. Nil policies,
// converter failures, map-key collisions and deep reference isolation still
// require runtime checks. Options remain local; no mapping files or registration.
func Check[S, T any](options ...Option) error {
c, err := New(options...)
if err != nil {
return err
}
return c.Check(reflect.TypeFor[S](), reflect.TypeFor[T]())
}
// Check validates types with an existing Copier's immutable settings. The target
// is the result type (not Copy's extra output pointer). Safe for concurrent use.
func (c *Copier) Check(source, target reflect.Type) error {
if c == nil {
return errors.New("copyx: nil Copier")
}
if source == nil || target == nil {
return errors.New("copyx: source and target types are required")
}
return newTypeChecker(&c.options, false).check(source, target, "$", 0)
}
type typeChecker struct {
options *settings
allowDynamic bool
seen map[pair]bool
nodes int
}
func newTypeChecker(options *settings, allowDynamic bool) *typeChecker {
return &typeChecker{options: options, allowDynamic: allowDynamic, seen: map[pair]bool{}}
}
// Absent elements can reveal type/field mismatches, but contain no actual
// resource handles or dynamic interface values to validate. Runtime checks stay
// responsible for those value-dependent cases.
func (s *state) checkAbsent(from, to reflect.Type, path string) error {
if s.checkedTypes == nil {
s.checkedTypes = map[pair]bool{}
}
checker := typeChecker{options: s.options, allowDynamic: true, seen: s.checkedTypes, nodes: s.checkedNodes}
err := checker.check(from, to, path, 0)
s.checkedNodes = checker.nodes
return err
}
func (c *typeChecker) check(from, to reflect.Type, path string, depth int) error {
c.nodes++
if depth > maxDepth || c.nodes > maxNodes {
return at(path, ErrLimit)
}
key := pair{from, to}
if c.seen[key] {
return nil
}
c.seen[key] = true
if !c.allowDynamic && !shallowSafe(to) {
return at(path, ErrUnsupported)
}
if _, ok := c.options.converters[key]; ok {
return nil
}
if from == to && len(c.options.converters) == 0 && len(c.options.fields) == 0 && !c.options.skipZero && c.options.nilPolicy != NilSkip {
if !c.options.deep || simpleValue(to) {
return nil
}
}
if from.Kind() == reflect.Interface {
if c.allowDynamic {
return nil
}
return at(path, ErrDynamicType)
}
if to.Kind() == reflect.Interface {
if !from.Implements(to) {
return at(path, ErrType)
}
return c.check(from, from, path, depth+1)
}
if !c.allowDynamic && c.options.deep && forbidden(to) {
return at(path, ErrUnsupported)
}
if from.Kind() == reflect.Pointer && to.Kind() != reflect.Pointer {
return c.check(from.Elem(), to, path, depth+1)
}
if to.Kind() == reflect.Pointer {
if !c.options.deep && from == to {
return nil
}
if from.Kind() == reflect.Pointer {
from = from.Elem()
}
return c.check(from, to.Elem(), path, depth+1)
}
if from.Kind() != to.Kind() {
return at(path, fmt.Errorf("%w: %s -> %s", ErrType, from, to))
}
if to == timeType && from == to {
return nil
}
switch to.Kind() {
case reflect.Struct:
for i := 0; i < to.NumField(); i++ {
f := to.Field(i)
if !c.allowDynamic && f.PkgPath != "" && (from != to || c.options.deep && privateMutable(f.Type)) {
return at(path+"."+f.Name, ErrUnsupported)
}
}
sources, targets := exportedFields(from), exportedFields(to)
rules := c.options.fields[key]
if rules == nil {
rules = &fields{}
}
reverse, used := map[string]string{}, map[string]bool{}
for a, b := range rules.names {
reverse[b] = a
}
for _, name := range sortedFieldNames(targets) {
if rules.ignoreTarget[name] {
continue
}
sourceName := name
if n, ok := reverse[name]; ok {
sourceName = n
}
source, ok := sources[sourceName]
if !ok || rules.ignoreSource[sourceName] {
return at(path+"."+name, ErrField)
}
used[sourceName] = true
if err := c.check(source.Type, targets[name].Type, path+"."+name, depth+1); err != nil {
return err
}
}
if c.options.strict {
for _, name := range sortedFieldNames(sources) {
if !used[name] && !rules.ignoreSource[name] {
return at(path+"."+name, ErrField)
}
}
}
return nil
case reflect.Slice:
if !c.options.deep && from == to {
return nil
}
return c.check(from.Elem(), to.Elem(), path+"[]", depth+1)
case reflect.Array:
if from.Len() != to.Len() {
return at(path, ErrType)
}
return c.check(from.Elem(), to.Elem(), path+"[]", depth+1)
case reflect.Map:
if !c.options.deep && from == to {
return nil
}
if err := c.check(from.Key(), to.Key(), path+"[key]", depth+1); err != nil {
return err
}
return c.check(from.Elem(), to.Elem(), path+"[value]", depth+1)
case reflect.Func, reflect.Chan, reflect.UnsafePointer:
if c.options.deep && !c.allowDynamic {
return at(path, ErrUnsupported)
}
}
if from != to {
return at(path, fmt.Errorf("%w: %s -> %s", ErrType, from, to))
}
return nil
}
func sortedFieldNames(fields map[string]reflect.StructField) []string {
names := make([]string, 0, len(fields))
for name := range fields {
names = append(names, name)
}
sort.Strings(names)
return names
}

192
copyx/check_test.go Normal file
View File

@@ -0,0 +1,192 @@
package copyx_test
import (
"errors"
"reflect"
"strings"
"sync"
"testing"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
type checkSource struct{ Rows []checkSourceRow }
type checkSourceRow struct{ Value string }
type checkTarget struct{ Rows []checkTargetRow }
type checkTargetRow struct{ Value int }
type recursiveSource struct {
Children []*recursiveSource
Value string
}
type recursiveTarget struct {
Children []*recursiveTarget
Value int
}
type recursiveCompatible struct {
Children []*recursiveCompatible
Value string
}
func TestCheckTypesAndEmptyCollections(t *testing.T) {
err := copyx.Check[checkSource, checkTarget]()
var field *copyx.Error
if !errors.Is(err, copyx.ErrType) || !errors.As(err, &field) || field.Path != "$.Rows[].Value" {
t.Fatal(err)
}
for _, rows := range [][]checkSourceRow{nil, {}, {{Value: "42"}}} {
out := checkTarget{Rows: []checkTargetRow{{Value: 99}}}
if err := copyx.Copy(&out, checkSource{rows}); !errors.Is(err, copyx.ErrType) {
t.Fatalf("rows %v: %v", rows, err)
}
if len(out.Rows) != 1 || out.Rows[0].Value != 99 {
t.Fatal("partial target committed")
}
}
if err := copyx.Check[map[string]checkSourceRow, map[string]checkTargetRow](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
var dst map[string]checkTargetRow
if err := copyx.Copy(&dst, map[string]checkSourceRow{}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
var array [0]checkTargetRow
if err := copyx.Copy(&array, [0]checkSourceRow{}); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[map[string]int, map[int]int](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[[2]int, [3]int](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[*recursiveSource, *recursiveTarget](); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
if err := copyx.Check[*recursiveSource, *recursiveCompatible](copyx.WithDeepCopy()); err != nil {
t.Fatal(err)
}
}
func TestCheckRulesAndConverterBoundaries(t *testing.T) {
type from struct {
Name string
Extra bool
}
type to struct {
Label string
Keep int
}
options := []copyx.Option{copyx.WithStrictFields(), copyx.WithFieldNames[from, to](map[string]string{"Name": "Label"}), copyx.WithIgnoreSource[from, to]("Extra"), copyx.WithIgnoreTarget[from, to]("Keep")}
if err := copyx.Check[from, to](options...); err != nil {
t.Fatal(err)
}
if err := copyx.Check[from, to](); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
type short struct{ Name string }
if err := copyx.Check[from, short](copyx.WithStrictFields()); !errors.Is(err, copyx.ErrField) {
t.Fatal(err)
}
if err := copyx.Check[checkSource, checkTarget](copyx.WithConverter(func(string) (int, error) { panic("check must not execute converters") })); err != nil {
t.Fatal(err)
}
if err := copyx.Check[struct{ Value any }, struct{ Value int }](); !errors.Is(err, copyx.ErrDynamicType) {
t.Fatal(err)
}
if err := copyx.Check[any, int](copyx.WithConverter(func(any) (int, error) { panic("not called") })); err != nil {
t.Fatal(err)
}
if err := copyx.Check[*int, int](); err != nil {
t.Fatal(err)
} // Actual nil remains a runtime error.
var src *int
dst := 1
if err := copyx.Copy(&dst, src); !errors.Is(err, copyx.ErrNil) || dst != 1 {
t.Fatal(err)
}
var empty []int
if err := copyx.Copy(&empty, []any{}); err != nil || empty == nil {
t.Fatal("dynamic emptiness changed", err)
}
if err := copyx.Check[sync.Mutex, sync.Mutex](); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
option := copyx.WithConverter(func(v []int) ([]int, error) { return v, nil })
if err := copyx.Check[[]int, []int](copyx.WithDeepCopy(), option); err != nil {
t.Fatal(err)
}
if _, err := copyx.Clone([]int{1}, option); !errors.Is(err, copyx.ErrSharedReference) {
t.Fatal("type check bypassed graph audit", err)
}
}
func TestCopierCheckConcurrentAndOptionsIsolation(t *testing.T) {
good, _ := copyx.New(copyx.WithConverter(func(string) (int, error) { return 42, nil }))
bad, _ := copyx.New()
var wg sync.WaitGroup
for i := 0; i < 24; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 20; j++ {
if err := good.Check(reflect.TypeFor[checkSource](), reflect.TypeFor[checkTarget]()); err != nil {
t.Error(err)
}
if err := bad.Check(reflect.TypeFor[checkSource](), reflect.TypeFor[checkTarget]()); !errors.Is(err, copyx.ErrType) {
t.Error(err)
}
var target checkTarget
if err := good.Copy(&target, checkSource{Rows: []checkSourceRow{}}); err != nil || target.Rows == nil {
t.Error(err)
}
}
}()
}
wg.Wait()
if err := good.Check(nil, reflect.TypeFor[int]()); err == nil {
t.Fatal("nil type accepted")
}
var absent *copyx.Copier
if err := absent.Check(reflect.TypeFor[int](), reflect.TypeFor[int]()); err == nil {
t.Fatal("nil copier accepted")
}
if err := copyx.Check[int, string](); err == nil || !strings.Contains(err.Error(), "int -> string") {
t.Fatal(err)
}
}
func BenchmarkTypeCheck(b *testing.B) {
c, _ := copyx.New(copyx.WithConverter(func(string) (int, error) { return 1, nil }))
from, to := reflect.TypeFor[checkSource](), reflect.TypeFor[checkTarget]()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := c.Check(from, to); err != nil {
b.Fatal(err)
}
}
}
func TestEmptyTypeCheckPreservesAbsentValueSemantics(t *testing.T) {
var callback func()
if out, err := copyx.Clone(callback); err != nil || out != nil {
t.Fatal(err)
}
if out, err := copyx.Clone([]func(){}); err != nil || out == nil || len(out) != 0 {
t.Fatal(err)
}
if _, err := copyx.Clone([]func(){func() {}}); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal(err)
}
if err := copyx.Check[[]func(), []func()](copyx.WithDeepCopy()); !errors.Is(err, copyx.ErrUnsupported) {
t.Fatal("preflight must report potentially non-nil handles", err)
}
type from struct {
Ignored *int
Value string
}
type to struct {
Ignored *int
Value int
}
var dst []to
if err := copyx.Copy(&dst, []from{}, copyx.WithDeepCopy()); !errors.Is(err, copyx.ErrType) {
t.Fatal(err)
}
}

View File

@@ -89,12 +89,14 @@ type visit struct {
target reflect.Type
}
type state struct {
options *settings
seen map[visit]reflect.Value
graph *graph
nodes int
allocated uint64
valueRoot *reference
options *settings
seen map[visit]reflect.Value
graph *graph
nodes int
allocated uint64
valueRoot *reference
checkedTypes map[pair]bool
checkedNodes int
}
func (s *state) reserve(count int, size uintptr, path string) error {
@@ -239,6 +241,11 @@ func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth
case reflect.Struct:
return s.structure(src, base, t, path, depth)
case reflect.Slice:
if src.Len() == 0 {
if err := s.checkAbsent(src.Type(), t, path); err != nil {
return reflect.Value{}, err
}
}
if !s.options.deep && src.Type() == t {
return src, nil
}
@@ -264,6 +271,11 @@ func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth
}
return out, nil
case reflect.Array:
if src.Len() == 0 {
if err := s.checkAbsent(src.Type(), t, path); err != nil {
return reflect.Value{}, err
}
}
if src.Len() != t.Len() {
return reflect.Value{}, at(path, ErrType)
}
@@ -280,6 +292,11 @@ func (s *state) walk(src, base reflect.Value, t reflect.Type, path string, depth
}
return out, nil
case reflect.Map:
if src.Len() == 0 {
if err := s.checkAbsent(src.Type(), t, path); err != nil {
return reflect.Value{}, err
}
}
if !s.options.deep && src.Type() == t {
return src, nil
}

82
cors/README.md Normal file
View File

@@ -0,0 +1,82 @@
# 可选 CORS
CORS 控制浏览器能否跨源读取 HTTP 响应。默认关闭;同源反向代理可以保持关闭。模块无 Gin 或具体业务依赖,标准库/native/HTTP2 与自定义 Runner 使用相同 App 接入策略。
## 生成项目只改配置
在既有 `etc/config.yaml` 的 `server` 中加入,无需修改 main、svc 或每条 `.api` 路由:
```yaml
server:
cors:
enabled: true
allowedOrigins:
- https://admin.example.com
- https://merchant.example.com
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
allowedHeaders: [Authorization, Content-Type]
exposedHeaders: [X-Request-ID, Content-Disposition]
allowCredentials: false
maxAge: 10m
```
文件也可显式引用环境变量:`allowedOrigins: ["${WEB_ORIGIN}"]`。一个数组元素代表一个来源,不把逗号分隔环境文本隐式拆成多个来源。JSON/YAML/XML 走同一加载和校验;非法配置定位 `server.cors.allowedOrigins[0]` 等字段、文件及可用环境名/行列,不回显值。
```sh
go run . -config etc/config.yaml -check-config
go run ./examples/cors # 在框架源码根目录运行独立示例
```
## 手写项目与独立 HTTP 服务
```go
app := omni.New(omni.WithCORS(omni.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://web.example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
}))
```
已有 net/http 服务也可单独使用,不需要创建 App:
```go
policy, err := cors.New(cors.Config{
Enabled: true,
AllowedOrigins: []string{"https://web.example.com"},
})
if err != nil { return err }
handler := policy.Wrap(existingHandler)
```
Config/New/WithCORS 在初始化时校验并快照配置;同一 Policy 可并发复用。`Config.Validate()` 返回 error,直接向 `omni.New` 传非法选项沿用框架初始化 panic 约定;生成项目在创建 App 前已校验。
## 配置规则
| 字段 | 规则 |
| --- | --- |
| enabled | 默认 false;关闭时直接使用原 Handler,不添加头、不拦截 OPTIONS |
| allowedOrigins | 启用时必填。完整 HTTP(S) origin(协议+主机+可选端口),无路径、尾斜杠、userinfo、查询或片段。配置中的协议/主机转小写、默认 80/443 端口省略;请求匹配浏览器序列化后的来源 |
| allowedMethods | 空列表默认 GET、HEAD、POST;填写后使用指定集合。大写方法名,拒绝浏览器禁止的 CONNECT/TRACE/TRACK,不提供方法通配符 |
| allowedHeaders | 空列表不批准额外预检请求头。Authorization、JSON Content-Type 等按需要列出;名称忽略大小写,不提供通配符 |
| exposedHeaders | 默认空;额外允许前端 JS 读取的响应头,例如下载文件名用 Content-Disposition。浏览器本来就可读取的安全列表不需重复填写 |
| allowCredentials | 默认 false;跨源 Cookie 等凭据模式需要显式 true 和具体来源。单纯手动设置 Authorization 头只需列入 allowedHeaders,不必因此开启 Cookie 凭据模式 |
| maxAge | 浏览器缓存预检结果的时长,必须非负且为整秒;0 不发送该头。浏览器可按自身上限截短 |
公开无凭据 API 可使用唯一来源 `"*"`;与 `allowCredentials: true` 或其他来源并存会报错。此版本只支持 HTTP(S) 来源,拒绝 `null`、子域通配符、正则、file:// 和不透明来源。国际化域名填写浏览器发送的 ASCII/Punycode 形式;IP 使用完整标准形式。不会为匹配来源查询 DNS。
## 预检、错误响应与安全边界
- CORS 位于路由/业务中间件外层。只有 OPTIONS 且同时带 Origin 和 Access-Control-Request-Method 才视为预检;普通 OPTIONS 继续执行项目逻辑。
- 合法预检返回 **204 空响应**,不进入业务中间件、鉴权、Logic 或业务响应包装。拒绝预检返回 **403 空响应**,不暴露内部原因;两者是协议响应,不受业务“失败也返回 HTTP 200”的设置影响。
- 预检验证全局来源、方法、请求头策略,不探测路由存在性;通过不代表实际接口一定存在。实际请求仍由路由处理,可返回 404/405。
- 实际请求继续正常鉴权;来源/方法获准时,正常响应、401/403、404/405、框架恢复的 500 都带 CORS 头,响应体、状态和错误目录不变。协议解析、TLS、网络层过载等在进入 App 前发生的拒绝不由本模块改写。
- 未获准或没有 Origin 的实际请求仍正常进入业务,只是不添加 CORS 许可头。CORS 不等于身份认证、服务端访问控制或 CSRF 防护;服务端回调仍需项目自己的验签/鉴权。
- 始终追加并去重 `Vary: Origin`;预检再追加请求方法/请求头的 Vary,保留原有 Vary 与 `*`。不要在下游代码覆盖这些头。代理与应用选择一处负责 CORS,避免重复头或冲突策略。
- 不包装 ResponseWriter、不读取/缓存 body、不克隆 Request。下载、上传、Flush、Hijack 和请求上下文保留;不增加响应体大小限制或改写统一返回格式。
- 使用 App、Engine、NewServer 或 Run/ServeContext/RunWith 接入。Router() 暴露底层路由供高级注册,单独拿 Router().ServeHTTP 服务会绕过应用的外层策略。
- App 内业务日志/指标不统计被提前结束的预检;如需记录全部流量,在 App 外层或代理配置访问日志。不要让全局登录校验挡在 CORS 预检之前。
配置最多 1024 个来源、每个最多 4096 字节;方法/请求头/暴露头各最多 128 个且名称最多 256 字节;预检请求头列表最多 128 项、8192 字节。拒绝 CRLF、歧义的重复 Origin/请求方法及非法 token。WebSocket Origin 校验、Private Network Access 扩展和云桶 CORS 不包含在本模块中,按对应服务配置。
规则依据:[Fetch CORS 协议](https://fetch.spec.whatwg.org/#http-cors-protocol)、[凭据规则](https://fetch.spec.whatwg.org/#cors-protocol-and-credentials)、[缓存规则](https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches)。可运行示例:[examples/cors](../examples/cors/README.md)。

144
cors/config.go Normal file
View File

@@ -0,0 +1,144 @@
// Package cors implements optional, transport-independent browser CORS policy.
// It does not authenticate requests or provide CSRF protection.
package cors
import (
"net"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// Config is disabled by default. Origins are exact HTTP(S) origins, or a single
// "*" for public responses without credentials. Patterns and "null" are rejected.
// Empty methods default to GET, HEAD, POST; empty header lists grant no extra
// permissions. MaxAge is a nonnegative whole number of seconds; zero omits it.
type Config struct {
Enabled bool `json:"enabled"`
AllowedOrigins []string `json:"allowedOrigins"`
AllowedMethods []string `json:"allowedMethods"`
AllowedHeaders []string `json:"allowedHeaders"`
ExposedHeaders []string `json:"exposedHeaders"`
AllowCredentials bool `json:"allowCredentials"`
MaxAge time.Duration `json:"maxAge"`
}
func (c Config) Clone() Config {
c.AllowedOrigins = append([]string(nil), c.AllowedOrigins...)
c.AllowedMethods = append([]string(nil), c.AllowedMethods...)
c.AllowedHeaders = append([]string(nil), c.AllowedHeaders...)
c.ExposedHeaders = append([]string(nil), c.ExposedHeaders...)
return c
}
// Validate never echoes configured values in diagnostics. Invalid settings are
// rejected even when disabled; an empty origin list is legal only when disabled.
func (c Config) Validate() error {
if c.Enabled && len(c.AllowedOrigins) == 0 {
return configdiag.New("allowedOrigins", "is required when CORS is enabled")
}
if c.MaxAge < 0 || c.MaxAge%time.Second != 0 {
return configdiag.New("maxAge", "must be a nonnegative whole number of seconds")
}
if len(c.AllowedOrigins) > 1024 {
return configdiag.New("allowedOrigins", "must contain at most 1024 entries")
}
for i, origin := range c.AllowedOrigins {
field := "allowedOrigins[" + strconv.Itoa(i) + "]"
if origin == "*" {
if c.AllowCredentials || len(c.AllowedOrigins) != 1 {
return configdiag.New(field, "wildcard requires a single origin entry and allowCredentials=false")
}
} else if _, ok := normalizeOrigin(origin); !ok {
return configdiag.New(field, "must be an HTTP(S) origin without credentials, path, query, fragment or wildcard")
}
}
for _, list := range []struct {
name string
values []string
}{{"allowedMethods", c.AllowedMethods}, {"allowedHeaders", c.AllowedHeaders}, {"exposedHeaders", c.ExposedHeaders}} {
if len(list.values) > 128 {
return configdiag.New(list.name, "must contain at most 128 entries")
}
for i, value := range list.values {
field := list.name + "[" + strconv.Itoa(i) + "]"
if !token(value) || value == "*" {
return configdiag.New(field, "must be an explicit HTTP token, not a wildcard")
}
if list.name == "allowedMethods" && (value != strings.ToUpper(value) || value == "CONNECT" || value == "TRACE" || value == "TRACK") {
return configdiag.New(field, "must be an uppercase browser-allowed HTTP method")
}
}
}
return nil
}
// Normalize configured scheme/host case and default ports once. Request values
// match serialized browser origins; arbitrary URL prefixes/suffixes never match.
func normalizeOrigin(origin string) (string, bool) {
if len(origin) == 0 || len(origin) > 4096 {
return "", false
}
for _, b := range []byte(origin) {
if b <= 32 || b >= 127 || strings.ContainsRune("\\%?#*", rune(b)) {
return "", false
}
}
u, err := url.Parse(origin)
if err != nil || u.User != nil || u.Opaque != "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Host == "" {
return "", false
}
scheme, host := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname())
if (scheme != "http" && scheme != "https") || host == "" || strings.HasSuffix(u.Host, ":") {
return "", false
}
if strings.Contains(host, ":") {
ip, err := netip.ParseAddr(host)
if err != nil || !ip.Is6() || ip.Zone() != "" {
return "", false
}
host = "[" + ip.String() + "]"
if ip.Is4In6() {
// Browsers serialize mapped IPv4 as hex IPv6 pieces; netip.String
// uses a dotted-decimal tail instead.
b := ip.As16()
host = "[::ffff:" + strconv.FormatUint(uint64(b[12])<<8|uint64(b[13]), 16) + ":" + strconv.FormatUint(uint64(b[14])<<8|uint64(b[15]), 16) + "]"
}
} else {
if strings.ContainsAny(u.Host, "[]") {
return "", false
}
for _, b := range []byte(host) {
if !(b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '.' || b == '-') {
return "", false
}
}
}
port := u.Port()
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "", false
}
if !(scheme == "https" && n == 443 || scheme == "http" && n == 80) {
host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(n))
}
}
return scheme + "://" + host, true
}
func token(s string) bool {
if s == "" || len(s) > 256 {
return false
}
for _, b := range []byte(s) {
if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(b))) {
return false
}
}
return true
}

187
cors/cors.go Normal file
View File

@@ -0,0 +1,187 @@
package cors
import (
"net/http"
"strconv"
"strings"
"time"
)
// Policy is immutable after New and safe to share between concurrent requests.
type Policy struct {
enabled, wildcard, credentials bool
origins, methods, headers map[string]struct{}
allowMethods, allowHeaders, exposeHeaders, maxAge string
}
// New validates and snapshots policy. It performs no network or DNS operations.
func New(c Config) (*Policy, error) {
if err := c.Validate(); err != nil {
return nil, err
}
p := &Policy{enabled: c.Enabled, credentials: c.AllowCredentials}
if !c.Enabled {
return p, nil
}
p.origins = make(map[string]struct{}, len(c.AllowedOrigins))
for _, origin := range c.AllowedOrigins {
if origin == "*" {
p.wildcard = true
continue
}
normalized, _ := normalizeOrigin(origin)
p.origins[normalized] = struct{}{}
}
methods := c.AllowedMethods
if len(methods) == 0 {
methods = []string{http.MethodGet, http.MethodHead, http.MethodPost}
}
p.methods, p.allowMethods = compileList(methods, false)
p.headers, p.allowHeaders = compileList(c.AllowedHeaders, true)
_, p.exposeHeaders = compileList(c.ExposedHeaders, true)
if c.MaxAge > 0 {
p.maxAge = strconv.FormatInt(int64(c.MaxAge/time.Second), 10)
}
return p, nil
}
func compileList(values []string, header bool) (map[string]struct{}, string) {
set := make(map[string]struct{}, len(values))
list := make([]string, 0, len(values))
for _, value := range values {
key := value
if header {
key = strings.ToLower(value)
value = http.CanonicalHeaderKey(value)
}
if _, exists := set[key]; exists {
continue
}
set[key] = struct{}{}
list = append(list, value)
}
return set, strings.Join(list, ", ")
}
// Wrap installs CORS outside routing/authentication. Only actual CORS preflights
// are short-circuited. Actual requests always continue through normal handlers;
// an unlisted Origin receives no permission to read the response in a browser.
// The original ResponseWriter, Request and body pass through without buffering.
func (p *Policy) Wrap(next http.Handler) http.Handler {
if next == nil {
panic("cors: nil handler")
}
if p == nil {
panic("cors: nil policy")
}
if !p.enabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
vary(h, "Origin")
origins := r.Header.Values("Origin")
// An OPTIONS request without Origin or Access-Control-Request-Method is
// ordinary application traffic, including user-defined OPTIONS handlers.
requestedMethods := r.Header.Values("Access-Control-Request-Method")
preflight := r.Method == http.MethodOptions && len(origins) > 0 && len(requestedMethods) > 0
if preflight {
vary(h, "Access-Control-Request-Method")
vary(h, "Access-Control-Request-Headers")
}
if len(origins) != 1 || !p.allowsOrigin(origins[0]) {
if preflight {
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
if preflight {
if len(requestedMethods) != 1 || !p.allowsMethod(requestedMethods[0]) || !p.allowsHeaders(r.Header.Values("Access-Control-Request-Headers")) {
w.WriteHeader(http.StatusForbidden)
return
}
p.allowOrigin(h, origins[0])
h.Set("Access-Control-Allow-Methods", p.allowMethods)
if p.allowHeaders != "" {
h.Set("Access-Control-Allow-Headers", p.allowHeaders)
}
if p.maxAge != "" {
h.Set("Access-Control-Max-Age", p.maxAge)
}
w.WriteHeader(http.StatusNoContent)
return
}
// Match the actual method too: this grants response access, not permission
// to execute a side effect. Authentication/CSRF checks remain in the app.
if p.allowsMethod(r.Method) {
p.allowOrigin(h, origins[0])
if p.exposeHeaders != "" {
h.Set("Access-Control-Expose-Headers", p.exposeHeaders)
}
}
next.ServeHTTP(w, r)
})
}
func (p *Policy) allowsOrigin(origin string) bool {
if !p.wildcard {
_, ok := p.origins[origin]
return ok
}
normalized, ok := normalizeOrigin(origin)
return ok && normalized == origin
}
func (p *Policy) allowsMethod(method string) bool { _, ok := p.methods[method]; return ok }
func (p *Policy) allowOrigin(h http.Header, origin string) {
if p.wildcard {
origin = "*"
}
h.Set("Access-Control-Allow-Origin", origin)
if p.credentials {
h.Set("Access-Control-Allow-Credentials", "true")
}
}
func (p *Policy) allowsHeaders(lines []string) bool {
count, size := 0, 0
for _, line := range lines {
size += len(line)
if size > 8192 {
return false
}
// Parse a header list without allocating an attacker-sized split slice.
for {
name, rest, more := strings.Cut(line, ",")
name = strings.Trim(name, " \t")
count++
if count > 128 || !token(name) {
return false
}
if _, ok := p.headers[strings.ToLower(name)]; !ok {
return false
}
if !more {
break
}
line = rest
}
}
return true
}
func vary(h http.Header, name string) {
for _, line := range h.Values("Vary") {
for {
v, rest, more := strings.Cut(line, ",")
v = strings.TrimSpace(v)
if v == "*" || strings.EqualFold(v, name) {
return
}
if !more {
break
}
line = rest
}
}
h.Add("Vary", name)
}

303
cors/cors_test.go Normal file
View File

@@ -0,0 +1,303 @@
package cors
import (
"context"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func exampleConfig() Config {
return Config{Enabled: true, AllowedOrigins: []string{"https://admin.example", "https://merchant.example"}, AllowedMethods: []string{"GET", "POST", "DELETE"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"X-Request-ID", "Content-Disposition"}, MaxAge: 10 * time.Minute}
}
func TestPolicyPreflightAndActual(t *testing.T) {
for _, tc := range []struct {
name, method, origin, requestedMethod, requestedHeaders string
status int
called, allowed bool
}{
{"preflight", "OPTIONS", "https://admin.example", "POST", "authorization, CONTENT-TYPE", 204, false, true},
{"no request headers", "OPTIONS", "https://admin.example", "DELETE", "", 204, false, true},
{"unknown origin", "OPTIONS", "https://other.example", "POST", "authorization", 403, false, false},
{"suffix attack", "OPTIONS", "https://admin.example.evil", "POST", "", 403, false, false},
{"scheme mismatch", "OPTIONS", "http://admin.example", "POST", "", 403, false, false},
{"port mismatch", "OPTIONS", "https://admin.example:444", "POST", "", 403, false, false},
{"opaque origin", "OPTIONS", "null", "POST", "", 403, false, false},
{"unknown method", "OPTIONS", "https://admin.example", "PATCH", "", 403, false, false},
{"case sensitive method", "OPTIONS", "https://admin.example", "post", "", 403, false, false},
{"unknown header", "OPTIONS", "https://admin.example", "POST", "X-Internal", 403, false, false},
{"trailing empty header", "OPTIONS", "https://admin.example", "POST", "Authorization,", 403, false, false},
{"control header", "OPTIONS", "https://admin.example", "POST", "Authorization\r\nX-Evil", 403, false, false},
{"duplicate allowed header", "OPTIONS", "https://admin.example", "POST", "Authorization, authorization", 204, false, true},
{"oversized list", "OPTIONS", "https://admin.example", "POST", strings.Repeat("Authorization,", 129) + "Authorization", 403, false, false},
{"actual error response", "GET", "https://merchant.example", "", "", 401, true, true},
{"actual denied origin still authenticates", "POST", "https://other.example", "", "", 401, true, false},
{"actual denied method still authenticates", "PUT", "https://admin.example", "", "", 401, true, false},
{"server callback", "POST", "", "", "", 401, true, false},
{"ordinary OPTIONS", "OPTIONS", "https://admin.example", "", "", 401, true, false},
{"OPTIONS without Origin", "OPTIONS", "", "POST", "", 401, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
policy, err := New(exampleConfig())
if err != nil {
t.Fatal(err)
}
called := false
h := policy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(401)
io.WriteString(w, "authentication required")
}))
r := httptest.NewRequest(tc.method, "http://api.example/resource", nil)
if tc.origin != "" {
r.Header.Set("Origin", tc.origin)
}
if tc.requestedMethod != "" {
r.Header.Set("Access-Control-Request-Method", tc.requestedMethod)
}
if tc.requestedHeaders != "" {
r.Header.Set("Access-Control-Request-Headers", tc.requestedHeaders)
}
w := httptest.NewRecorder()
w.Header().Add("Vary", "Accept-Encoding")
h.ServeHTTP(w, r)
if w.Code != tc.status || called != tc.called {
t.Fatalf("status=%d called=%v", w.Code, called)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); (got != "") != tc.allowed || tc.allowed && got != tc.origin {
t.Fatal(w.Header())
}
if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Origin") || !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Accept-Encoding") {
t.Fatal(w.Header())
}
if !tc.called {
if w.Body.Len() != 0 {
t.Fatal("preflight wrapped in business response")
}
if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Access-Control-Request-Headers") {
t.Fatal(w.Header())
}
if tc.allowed && (w.Header().Get("Access-Control-Max-Age") != "600" || !strings.Contains(w.Header().Get("Access-Control-Allow-Headers"), "Authorization")) {
t.Fatal(w.Header())
}
} else if w.Body.String() != "authentication required" {
t.Fatal(w.Body.String())
}
if tc.called && tc.allowed && w.Header().Get("Access-Control-Expose-Headers") != "X-Request-Id, Content-Disposition" {
t.Fatal(w.Header())
}
})
}
}
func TestPolicyValidation(t *testing.T) {
for _, origin := range []string{"https://admin.example/", "https://user:password@admin.example", "null", "https://*.example", "https://admin.example?secret", "https://admin.example#secret", "file://host", "https://host:", "https://host:0", "https://host:65536", "https://host%2eevil", "https://host\r\nX: value", "https://例子.example", "https://[localhost]", "https://[::1%25zone]"} {
c := exampleConfig()
c.AllowedOrigins = []string{origin}
err := c.Validate()
if err == nil || strings.Contains(err.Error(), origin) {
t.Fatalf("invalid origin accepted or exposed: %q %v", origin, err)
}
}
for _, change := range []func(*Config){
func(c *Config) { c.AllowedOrigins = nil },
func(c *Config) { c.AllowedOrigins = []string{"*"}; c.AllowCredentials = true },
func(c *Config) { c.AllowedOrigins = []string{"*", "https://admin.example"} },
func(c *Config) { c.AllowedHeaders = []string{"*"} },
func(c *Config) { c.ExposedHeaders = []string{"header\r\n"} },
func(c *Config) { c.AllowedMethods = []string{"get"} },
func(c *Config) { c.AllowedMethods = []string{"TRACE"} },
func(c *Config) { c.MaxAge = -time.Second },
func(c *Config) { c.MaxAge = time.Millisecond },
} {
c := exampleConfig()
change(&c)
if _, err := New(c); err == nil {
t.Fatal(c)
}
}
if err := (Config{}).Validate(); err != nil {
t.Fatal(err)
}
for input, want := range map[string]string{"https://[::ffff:192.0.2.1]": "https://[::ffff:c000:201]", "https://[::ffff:c000:201]": "https://[::ffff:c000:201]", "https://ADMIN.EXAMPLE:443": "https://admin.example", "http://localhost:80": "http://localhost", "http://localhost:8080": "http://localhost:8080", "https://[::1]:443": "https://[::1]", "http://[::1]:8080": "http://[::1]:8080"} {
got, ok := normalizeOrigin(input)
if !ok || got != want {
t.Fatalf("%q => %q %v", input, got, ok)
}
}
}
func TestPolicyCredentialsWildcardAndDuplicates(t *testing.T) {
for _, credentials := range []bool{false, true} {
c := exampleConfig()
c.AllowCredentials = credentials
if !credentials {
c.AllowedOrigins = []string{"*"}
}
p, _ := New(c)
for _, origin := range []string{"https://admin.example", "null", "https://admin.example/path", "https://bad\r\n"} {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, r)
if origin != "https://admin.example" {
if w.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal(w.Header())
}
continue
}
want := "*"
if credentials {
want = origin
}
if w.Header().Get("Access-Control-Allow-Origin") != want || (w.Header().Get("Access-Control-Allow-Credentials") == "true") != credentials {
t.Fatal(w.Header())
}
}
}
p, _ := New(exampleConfig())
for _, header := range []string{"Origin", "Access-Control-Request-Method"} {
r := httptest.NewRequest("OPTIONS", "/", nil)
r.Header.Set("Origin", "https://admin.example")
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Add(header, r.Header.Get(header))
w := httptest.NewRecorder()
p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("ambiguous preflight reached handler") })).ServeHTTP(w, r)
if w.Code != 403 || w.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal(w.Code, w.Header())
}
}
}
func TestPolicyPreservesHTTPAndSnapshot(t *testing.T) {
c := exampleConfig()
p, _ := New(c)
c.AllowedOrigins[0] = "https://evil.example"
c.AllowedHeaders[0] = "X-Evil"
r := httptest.NewRequest("GET", "/", strings.NewReader("request"))
r.Header.Set("Origin", "https://admin.example")
type key struct{}
r = r.WithContext(context.WithValue(r.Context(), key{}, "kept"))
w := httptest.NewRecorder()
w.Header().Set("Vary", "Accept-Encoding, origin")
next := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if writer != w || request != r || request.Context().Value(key{}) != "kept" {
t.Fatal("HTTP capabilities replaced")
}
io.WriteString(writer, "chunk")
writer.(http.Flusher).Flush()
})
p.Wrap(next).ServeHTTP(w, r)
if !w.Flushed || w.Body.String() != "chunk" || w.Header().Get("Access-Control-Allow-Origin") != "https://admin.example" || len(w.Header().Values("Vary")) != 1 {
t.Fatal(w)
}
for _, prior := range []string{"*", "Accept-Encoding, Origin"} {
h := http.Header{"Vary": {prior}}
vary(h, "Origin")
if !reflect.DeepEqual(h.Values("Vary"), []string{prior}) {
t.Fatal(h)
}
}
disabled, _ := New(Config{})
if reflect.ValueOf(disabled.Wrap(next)).Pointer() != reflect.ValueOf(next).Pointer() {
t.Fatal("disabled policy not passthrough")
}
var wg sync.WaitGroup
h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
for i := 0; i < 32; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 100; j++ {
r := httptest.NewRequest("GET", "/", nil)
origin := "https://admin.example"
if i%2 == 1 {
origin = "https://merchant.example"
}
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Header().Get("Access-Control-Allow-Origin") != origin {
t.Error("cross-request origin leak")
}
}
}(i)
}
wg.Wait()
}
func FuzzCORSHeaders(f *testing.F) {
f.Add("https://admin.example", "POST", "authorization, content-type")
f.Add("https://admin.example.evil", "POST", "")
f.Add("null", "GET", "\r\n")
p, _ := New(exampleConfig())
h := p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("preflight reached business") }))
f.Fuzz(func(t *testing.T, origin, method, headers string) {
r := httptest.NewRequest("OPTIONS", "/", nil)
r.Header.Set("Origin", origin)
r.Header.Set("Access-Control-Request-Method", method)
if headers != "" {
r.Header.Set("Access-Control-Request-Headers", headers)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 204 && w.Code != 403 {
t.Fatal(w.Code)
}
if w.Body.Len() != 0 {
t.Fatal("unexpected body")
}
if value := w.Header().Get("Access-Control-Allow-Origin"); value != "" {
if value != "https://admin.example" && value != "https://merchant.example" {
t.Fatal("untrusted reflection")
}
}
})
}
type benchmarkWriter struct{ header http.Header }
func (w benchmarkWriter) Header() http.Header { return w.header }
func (w benchmarkWriter) Write(p []byte) (int, error) { return len(p), nil }
func (w benchmarkWriter) WriteHeader(int) {}
func BenchmarkCORS(b *testing.B) {
for _, mode := range []string{"disabled", "noOrigin", "allowed", "denied", "preflight", "wildcard"} {
b.Run(mode, func(b *testing.B) {
c := exampleConfig()
if mode == "disabled" {
c.Enabled = false
}
if mode == "wildcard" {
c.AllowedOrigins = []string{"*"}
}
p, _ := New(c)
h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Origin", "https://admin.example")
if mode == "noOrigin" {
r.Header.Del("Origin")
}
if mode == "denied" {
r.Header.Set("Origin", "https://other.example")
}
if mode == "preflight" {
r.Method = "OPTIONS"
r.Header.Set("Access-Control-Request-Method", "POST")
r.Header.Set("Access-Control-Request-Headers", "authorization, content-type")
}
w := benchmarkWriter{make(http.Header)}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
clear(w.header)
h.ServeHTTP(w, r)
}
})
}
}

View File

@@ -6,10 +6,10 @@
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
- `Config`:驱动、DSN 与连接池参数;嵌入项目配置,通过文件显式引用变量。
- `Open` / `OpenConnector`:通过 DSN 或标准 Connector 创建连接池、设置连接上限并在返回前执行 `PingContext`。
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时尝试回滚。返回 `TxError` 区分阶段与结果,提交错误不代表已回滚。
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动:
@@ -17,11 +17,12 @@
```go
import (
"context"
"git.yuntuo.hk/k2safe/go-omni/config"
_ "github.com/go-sql-driver/mysql"
"git.yuntuo.hk/k2safe/go-omni/database"
)
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
cfg, err := config.LoadProject("etc/database.yaml", database.DefaultConfig())
if err != nil {
return err
}
@@ -32,19 +33,15 @@ if err != nil {
defer db.Close()
```
连接池相关环境变量:
对应文件(框架不规定变量名):
```text
GO_OMNI_DB_DRIVER=mysql
GO_OMNI_DB_DSN=user:password@tcp(127.0.0.1:3306)/app?parseTime=true
GO_OMNI_DB_MAX_OPEN_CONNS=10
GO_OMNI_DB_MAX_IDLE_CONNS=5
GO_OMNI_DB_CONN_MAX_LIFETIME=30m
GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
GO_OMNI_DB_PING_TIMEOUT=5s
```yaml
driver: mysql
dsn: "${DB_DSN}"
maxOpenConns: "${DB_MAX_OPEN:-10}"
```
使用 `ConfigFromEnv` / `Open` 时 `DSN` 必填,环境变量入口的驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
Open 使用的 Driver、DSN 必填。建库、迁移、表结构和业务 Repository 由项目负责。v0.22 删除 ConfigFromEnv/ApplyEnv;不再隐式扫描数据库变量。
`MaxIdleConns=0` 会禁用空闲连接。初始化 Ping 失败会关闭已创建的连接池。
@@ -62,12 +59,58 @@ python3 scripts/verifyDatabase.py --version v0.2.0
结束时清理容器、匿名数据卷和临时项目。常规 `go test ./...` 不依赖 Docker。
## 项目配置叠加
## 项目配置
`database.ApplyEnv(cfg, prefix)` 在已有文件/默认配置上覆盖非空环境变量,保留未覆盖的池参数。
`Config` 的 JSON/YAML 字段使用 lowerCamelCase,`DSN` 使用 `json:"-"`,连接密钥从环境变量加载。
API 脚手架 `--database mysql` 使用此流程,并要求正的 `maxOpenConns`,提供数据库 readiness 与幂等清理。
具体表结构、迁移和 Repository 留在业务项目,参考 `examples/userService`。
配置结构作为项目 Config 的字段,Default() 初始化连接池,文件按需覆盖。DSN 可使用 `dsn: "${DB_DSN}"`;database.Config 的 JSON 序列化不输出 DSN 原值。不要打印原始配置对象。当前 API 脚手架使用结构化 MySQL 适配器,由 svc 统一初始化与关闭。
## 事务阶段与结果
v0.25 起,`db.InTx(ctx, options, work)` 签名不变,所有普通错误返回都是 `*database.TxError`。返回 nil 表示 Commit 返回成功;框架不重试回调、提交或整个事务。业务通过标准库 errors.As/Is 判断,不解析错误文本:
```go
err := db.InTx(ctx, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "INSERT INTO objects (id, storage_key) VALUES (?, ?)", id, key)
return err
})
if err != nil {
var txErr *database.TxError
if errors.As(err, &txErr) {
switch txErr.Outcome {
case database.TxOutcomeNotStarted, database.TxOutcomeRolledBack:
// 当前受管理事务未执行回调或已确认回滚。
// 业务结合文件归属、并发操作和其他副作用,决定是否清理。
default:
// 包括 TxOutcomeUnknown:保留文件,交给业务后续核查。
// 不因为收到提交错误就删除文件或直接重试整个操作。
}
// txErr.Stage 是 begin / work / commit。
// txErr.Err 是主要原因;txErr.RollbackErr 是回滚调用返回的原因。
}
if errors.Is(err, context.DeadlineExceeded) {
// 说明错误链包含超时,不意味着数据库没有提交。
}
return err
}
```
`errors` 为标准库包,业务自己的数据库驱动和 SQL 参数规则不变。完整可运行示例见 [examples/transactions](../examples/transactions/README.md)。
| 情况 | Stage | Outcome | RollbackErr |
| --- | --- | --- | --- |
| 未初始化数据库、nil 回调、BeginTx 报错 | TxStageBegin | TxOutcomeNotStarted | nil;框架没有拿到事务,不执行回调 |
| 回调报错,显式 Rollback 成功 | TxStageWork | TxOutcomeRolledBack | nil |
| 回调报错,Rollback 失败或返回 ErrTxDone | TxStageWork | TxOutcomeUnknown | 原始回滚错误 |
| Commit 报错,包括取消/断网/驱动错误 | TxStageCommit | TxOutcomeUnknown | 后续清理的 Rollback 返回值,可能为 ErrTxDone 或 nil |
`Stage` 描述主要失败发生的位置;`Outcome` 只描述框架掌握的结果,二者独立。提交调用返回错误后一律保守标为 unknown,即使某个驱动的某种错误意味着没有提交,通用层也不根据错误文本猜测。标准库在 Commit 后通常已经结束本地事务对象,后续 Rollback 返回 `sql.ErrTxDone`;它不能证明数据库已回滚,框架保留该原因。
`TxError.Err` 与 `RollbackErr` 分开保存;`Unwrap() []error` 按主要原因、回滚原因顺序暴露,所以 errors.Is/As 可查两条链。需要区分来源时对 `txErr.Err` 或 `txErr.RollbackErr` 单独判断。回调返回另一个事务的 TxError 时外层不把它压平:外层结果只属于外层事务,内部事务的结果仍在 Err 中。
**结果保证的范围:** 回调使用给定 `*sql.Tx` 完成事务操作,不自行 Commit/Rollback,不执行会隐式提交的 SQL,不将事务交给后台 goroutine。非事务表、其他连接、嵌套的独立事务、文件和第三方调用的副作用不在这份保证内。框架没有删除文件、入队补偿或分布式事务规则。
**取消与异常边界:** context 取消时 database/sql 可能自行异步回滚,且不公开那次驱动 Rollback 的原始错误。框架若只观察到 ErrTxDone,就返回 unknown,不能补造成功证据或不存在的底层原因。正常返回路径上,框架实际观察到的主要/回滚原因全部保留。panic/runtime.Goexit 保持原有传播行为并尝试回滚,不转换为普通 error;这类展开没有错误返回值,因此不能把回滚原因作为返回值交给调用方。nil context 使用 Background,nil 回调在开启事务前报错。
**兼容说明:** 已有 errors.Is/As 继续生效;以前直接用 `err == sentinel`、`err.(*SomeError)` 或匹配文本的调用要改成 errors.Is/As。业务 APIError、Go 错误目录和参数提示仍可穿过包装解析,驱动错误不会自动进入公开提示。TxError.Error 包含内部原因,仅供内部诊断。GORM 自己的 Transaction 不经过本方法,也不会自动变成 TxError;可选 gormx 的连接池接入不变。
## Connector 接入(v0.9.1-preview)
@@ -103,7 +146,7 @@ func Open(ctx context.Context, address, user, password, name string,
}
```
调用时以 `database.DefaultPoolConfig()` 为起点,按项目需要调整。已有 `database.Config` 可调用 `.PoolConfig()` 提取池设置;`ConfigFromEnv` / `ApplyEnv` 仍是 DSN 入口,不用为 Connector 填假 DSN。`PoolConfig` 支持通过 `config.DecodeFile` 加载项目的 JSON/YAML 嵌套配置,环境覆盖由项目按自己的驱动配置组织。
调用时以 `database.DefaultPoolConfig()` 为起点,按项目需要调整。已有 `database.Config` 可调用 `.PoolConfig()` 提取池设置;Connector 不需要填写 DSN。`PoolConfig` 支持通过 `config.DecodeFile` 加载项目的 JSON/YAML 嵌套配置,环境引用由项目在文件里显式声明。
| PoolConfig 字段 | 默认值 / 含义 |
| --- | --- |

View File

@@ -2,19 +2,27 @@ package database
import (
"encoding/json"
"git.yuntuo.hk/k2safe/go-omni/config"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestApplyEnvPreservesFilePoolAndHidesDSN(t *testing.T) {
func TestExplicitConfigPreservesPoolAndHidesDSN(t *testing.T) {
cfg := DefaultConfig()
cfg.Driver = "mysql"
path := filepath.Join(t.TempDir(), "db.yaml")
if err := os.WriteFile(path, []byte("dsn: \"${OMNI_TEST_DSN}\"\nmaxIdleConns: \"${OMNI_TEST_MAX_IDLE_CONNS}\"\nmaxOpenConns: \"${OMNI_TEST_MAX_OPEN_CONNS:-3}\""), 0600); err != nil {
t.Fatal(err)
}
cfg.MaxOpenConns = 3
cfg.MaxIdleConns = 1
cfg.PingTimeout = time.Second
t.Setenv("OMNI_TEST_DSN", "user:secret@tcp(localhost:3306)/test")
t.Setenv("OMNI_TEST_MAX_IDLE_CONNS", "0")
got, err := ApplyEnv(cfg, "OMNI_TEST_")
got, err := config.LoadProject(path, cfg)
if err != nil {
t.Fatal(err)
}
@@ -29,7 +37,7 @@ func TestApplyEnvPreservesFilePoolAndHidesDSN(t *testing.T) {
t.Fatalf("unsafe config encoding: %s", data)
}
t.Setenv("OMNI_TEST_MAX_OPEN_CONNS", "bad")
if _, err = ApplyEnv(cfg, "OMNI_TEST_"); err == nil {
if _, err = config.LoadProject(path, cfg); err == nil {
t.Fatal("invalid override accepted")
}
}

View File

@@ -7,6 +7,8 @@ import (
"errors"
"reflect"
"time"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// PoolConfig contains only driver-independent settings. Connection credentials,
@@ -25,15 +27,29 @@ func DefaultPoolConfig() PoolConfig {
}
func (c PoolConfig) Validate() error {
if c.MaxOpenConns < 0 || c.MaxIdleConns < 0 {
return errors.New("database pool limits cannot be negative")
for _, f := range []struct {
name string
value int
}{{"maxOpenConns", c.MaxOpenConns}, {"maxIdleConns", c.MaxIdleConns}} {
if f.value < 0 {
return configdiag.New(f.name, "must be nonnegative")
}
}
if c.MaxOpenConns > 0 && c.MaxIdleConns > c.MaxOpenConns {
return errors.New("max idle connections cannot exceed max open connections")
return configdiag.New("maxIdleConns", "must not exceed maxOpenConns")
}
if c.ConnMaxLifetime < 0 || c.ConnMaxIdleTime < 0 || c.PingTimeout <= 0 {
return errors.New("database timeouts are invalid")
for _, f := range []struct {
name string
value time.Duration
}{{"connMaxLifetime", c.ConnMaxLifetime}, {"connMaxIdleTime", c.ConnMaxIdleTime}} {
if f.value < 0 {
return configdiag.New(f.name, "must be nonnegative")
}
}
if c.PingTimeout <= 0 {
return configdiag.New("pingTimeout", "must be positive")
}
return nil
}

View File

@@ -5,17 +5,18 @@ package database
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
type Config struct {
Driver string `json:"driver"`
DSN string `json:"-"`
DSN string `json:"dsn"`
MaxOpenConns int `json:"maxOpenConns"`
MaxIdleConns int `json:"maxIdleConns"`
ConnMaxLifetime time.Duration `json:"connMaxLifetime"`
@@ -23,6 +24,14 @@ type Config struct {
PingTimeout time.Duration `json:"pingTimeout"`
}
// MarshalJSON omits the connection credential from serialized diagnostics.
func (c Config) MarshalJSON() ([]byte, error) {
type plain Config
safe := plain(c)
safe.DSN = ""
return json.Marshal(safe)
}
func DefaultConfig() Config { return DefaultPoolConfig().config() }
// PoolConfig returns driver-independent connection pool settings.
@@ -34,55 +43,12 @@ func (c Config) PoolConfig() PoolConfig {
}
}
// ConfigFromEnv reads GO_OMNI_DB_* style settings. DSN is required.
func ConfigFromEnv(prefix string) (Config, error) {
return ApplyEnv(DefaultConfig(), prefix)
}
// ApplyEnv overlays nonempty environment settings on file/default configuration.
// DSN is environment-only when Config is decoded from JSON or YAML.
func ApplyEnv(cfg Config, prefix string) (Config, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
prefix = "GO_OMNI_DB_"
}
if value := strings.TrimSpace(os.Getenv(prefix + "DRIVER")); value != "" {
cfg.Driver = value
}
if cfg.Driver == "" {
cfg.Driver = "mysql"
}
if value := strings.TrimSpace(os.Getenv(prefix + "DSN")); value != "" {
cfg.DSN = value
}
if cfg.DSN == "" {
return Config{}, fmt.Errorf("%sDSN is required", prefix)
}
var err error
if cfg.MaxOpenConns, err = envInt(prefix+"MAX_OPEN_CONNS", cfg.MaxOpenConns); err != nil {
return Config{}, err
}
if cfg.MaxIdleConns, err = envInt(prefix+"MAX_IDLE_CONNS", cfg.MaxIdleConns); err != nil {
return Config{}, err
}
if cfg.ConnMaxLifetime, err = envDuration(prefix+"CONN_MAX_LIFETIME", cfg.ConnMaxLifetime); err != nil {
return Config{}, err
}
if cfg.ConnMaxIdleTime, err = envDuration(prefix+"CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime); err != nil {
return Config{}, err
}
if cfg.PingTimeout, err = envDuration(prefix+"PING_TIMEOUT", cfg.PingTimeout); err != nil {
return Config{}, err
}
return cfg, cfg.Validate()
}
func (c Config) Validate() error {
if strings.TrimSpace(c.Driver) == "" {
return errors.New("database driver is required")
return configdiag.New("driver", "is required")
}
if strings.TrimSpace(c.DSN) == "" {
return errors.New("database DSN is required")
return configdiag.New("dsn", "is required")
}
return c.PoolConfig().Validate()
}
@@ -141,51 +107,3 @@ func (db *DB) Health(ctx context.Context) error {
defer cancel()
return db.PingContext(checkCtx)
}
// InTx commits only when work returns nil. Any work error or panic rolls back.
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
if db == nil || db.DB == nil {
return errors.New("database is not initialized")
}
tx, err := db.BeginTx(ctx, options)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
rollbackErr := tx.Rollback()
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
}
}()
if err = work(tx); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func envInt(key string, fallback int) (int, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
}
return parsed, nil
}
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a duration: %w", key, err)
}
return parsed, nil
}

View File

@@ -2,54 +2,10 @@ package database
import (
"context"
"os"
"strings"
"testing"
"time"
)
func TestConfigFromEnv(t *testing.T) {
keys := []string{
"GO_OMNI_DB_DRIVER", "GO_OMNI_DB_DSN", "GO_OMNI_DB_MAX_OPEN_CONNS",
"GO_OMNI_DB_MAX_IDLE_CONNS", "GO_OMNI_DB_CONN_MAX_LIFETIME",
"GO_OMNI_DB_CONN_MAX_IDLE_TIME", "GO_OMNI_DB_PING_TIMEOUT",
}
for _, key := range keys {
t.Setenv(key, "")
_ = os.Unsetenv(key)
}
t.Setenv("GO_OMNI_DB_DSN", "root:secret@tcp(127.0.0.1:3306)/demo")
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "20")
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "8")
t.Setenv("GO_OMNI_DB_CONN_MAX_LIFETIME", "2m")
t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "750ms")
cfg, err := ConfigFromEnv("")
if err != nil {
t.Fatal(err)
}
if cfg.Driver != "mysql" || cfg.DSN == "" || cfg.MaxOpenConns != 20 || cfg.MaxIdleConns != 8 || cfg.ConnMaxLifetime != 2*time.Minute || cfg.PingTimeout != 750*time.Millisecond {
t.Fatalf("unexpected config: %+v", cfg)
}
}
func TestConfigFromEnvRejectsInvalidValues(t *testing.T) {
t.Setenv("GO_OMNI_DB_DSN", "demo")
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "bad")
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "MAX_OPEN_CONNS") {
t.Fatalf("expected invalid pool limit error, got %v", err)
}
t.Setenv("GO_OMNI_DB_MAX_OPEN_CONNS", "2")
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "3")
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "max idle") {
t.Fatalf("expected pool relationship error, got %v", err)
}
t.Setenv("GO_OMNI_DB_MAX_IDLE_CONNS", "1")
t.Setenv("GO_OMNI_DB_PING_TIMEOUT", "0s")
if _, err := ConfigFromEnv(""); err == nil || !strings.Contains(err.Error(), "timeouts") {
t.Fatalf("expected timeout error, got %v", err)
}
}
func TestOpenValidatesBeforeLoadingDriver(t *testing.T) {
_, err := Open(context.Background(), Config{Driver: "", DSN: "demo"})
if err == nil || !strings.Contains(err.Error(), "driver") {

View File

@@ -10,13 +10,14 @@
go-omni new --database mysql --module example.com/billing ./billing
```
`etc/config.yaml` 使用结构化配置,环境覆盖优先:
`etc/config.yaml` 使用结构化配置;需要环境变量时在字段值明确写引用,固定值不被覆盖:
```yaml
database:
host: db.example.com
port: 3306
user: app
password: "${DB_PASSWORD}"
database: billing
parseTime: true
location: UTC
@@ -37,7 +38,7 @@ database:
pingTimeout: 5s
```
通过 `GO_OMNI_DB_PASSWORD` 传密码;还有 HOST、PORT、USER、DATABASE、TLS_CA_FILE、TLS_CERT_FILE、TLS_KEY_FILE、POOL_MAX_OPEN_CONNS 等。业务也可自定义 env 标签。新生成服务不再读取 DSN;独立数据库迁移 CLI 和手写 `database.Open/OpenConnector` 的原接口保留。
示例通过文件里的 `${DB_PASSWORD}` 读取密码;HOST、PORT、TLS 和池参数同样按需在文件写引用,不自动扫描变量、不使用 env 标签。新生成服务使用结构化连接参数;独立迁移 CLI 的显式 DSN 参数及 Open/OpenConnector 接口保留。
默认启用 TLS 并校验系统信任链与主机名;私有 CA 用 caFile,双向 TLS 额外设置 certFile/keyFile。只有明确设置 `tls.mode: disabled` 才使用明文。Unix socket 用 socket,若在 socket 上使用 TLS,必须提供 serverName。不会注册全局 TLS 配置或全局驱动 Logger。
@@ -69,3 +70,7 @@ func (c *ServiceContext) DatabaseOptions() []mysqladapter.Option {
`WithDriverConfig(func(*mysql.Config) error)` 支持高级拨号、驱动参数等,配置在回调后克隆;这是项目明确选择的底层扩展。`NewConnector` 返回标准 driver.Connector,供已有基础设施使用;它不接受启动检查选项。任意驱动仍可用核心 `database.OpenConnector`。
错误有安全摘要,errors.Is/As 保留 TLS/驱动/项目检查的原因。配置验证不读证书、不连接数据库;Open 才读取证书、创建池并握手。不要自行打印原始配置或展开可能包含内部信息的 driver 字段给用户。
## 字段级配置错误
Validate 返回独立字段与安全原因,例如 port(1–65535)、readTimeout、pool.maxIdleConns、tls.keyFile。项目调用 `config.AtField("database", err)` 后,统一加载器补齐 database 前缀、文件、环境名和行列,无需重复写端口/连接池校验。TLS 成对配置会指出缺失的那个字段。连接失败、驱动和证书文件的内部原因仍通过 errors.Is/As 保留,不把凭据放进公开提示。见 [配置诊断](../../CONFIG.md#框架模块的字段诊断)。

View File

@@ -19,6 +19,7 @@ import (
"time"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
"git.yuntuo.hk/k2safe/go-omni/logx"
mysqldriver "github.com/go-sql-driver/mysql"
)
@@ -28,7 +29,7 @@ type Config struct {
Port int `json:"port"`
Socket string `json:"socket"`
User string `json:"user"`
Password string `json:"password" env:",secret"`
Password string `json:"password"`
Database string `json:"database"`
ParseTime bool `json:"parseTime"`
Location string `json:"location"`
@@ -61,45 +62,60 @@ var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate does not read certificate files or open connections. All defaults
// come from DefaultConfig; explicit zero I/O timeouts mean no driver deadline.
func (c Config) Validate() error {
if c.Socket == "" && (c.Host == "" || strings.ContainsAny(c.Host, "/\r\n\t ") || c.Port < 1 || c.Port > 65535) {
return errors.New("mysql: invalid host or port")
if c.Socket == "" {
if c.Host == "" || strings.ContainsAny(c.Host, "/\r\n\t ") {
return configdiag.New("host", "must be a nonempty hostname or IP address")
}
if c.Port < 1 || c.Port > 65535 {
return configdiag.New("port", "must be between 1 and 65535")
}
}
if strings.ContainsRune(c.Socket, 0) || strings.ContainsRune(c.Database, 0) || strings.ContainsRune(c.User, 0) {
return errors.New("mysql: invalid connection field")
for _, f := range []struct{ name, value string }{{"socket", c.Socket}, {"database", c.Database}, {"user", c.User}} {
if strings.ContainsRune(f.value, 0) {
return configdiag.New(f.name, "must not contain a NUL character")
}
}
if c.ConnectTimeout < 0 || c.ReadTimeout < 0 || c.WriteTimeout < 0 {
return errors.New("mysql: timeouts cannot be negative")
for _, f := range []struct {
name string
value time.Duration
}{{"connectTimeout", c.ConnectTimeout}, {"readTimeout", c.ReadTimeout}, {"writeTimeout", c.WriteTimeout}} {
if f.value < 0 {
return configdiag.New(f.name, "must be nonnegative")
}
}
if err := c.Pool.Validate(); err != nil {
return err
return configdiag.At("pool", err)
}
if c.Location != "" {
if _, err := time.LoadLocation(c.Location); err != nil {
return errors.New("mysql: invalid time location")
return configdiag.Wrap("location", "must be an available IANA time zone or UTC", err)
}
}
if c.Charset != "" && !identifier.MatchString(c.Charset) {
return errors.New("mysql: invalid charset")
return configdiag.New("charset", "must be a valid MySQL identifier")
}
if c.Collation != "" && !identifier.MatchString(c.Collation) {
return errors.New("mysql: invalid collation")
return configdiag.New("collation", "must be a valid MySQL identifier")
}
for key := range c.Session {
if !identifier.MatchString(key) {
return errors.New("mysql: invalid session variable name")
return configdiag.New("session", "keys must be valid MySQL identifiers")
}
}
if c.TLS.Mode != "" && c.TLS.Mode != "required" && c.TLS.Mode != "disabled" {
return errors.New("mysql: TLS mode must be required or disabled")
return configdiag.New("tls.mode", "must be required or disabled")
}
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return errors.New("mysql: TLS client certificate and key must be configured together")
if c.TLS.CertFile == "" {
return configdiag.New("tls.certFile", "is required when keyFile is configured")
}
return configdiag.New("tls.keyFile", "is required when certFile is configured")
}
if c.TLS.Mode == "disabled" && (c.TLS.CAFile != "" || c.TLS.CertFile != "" || c.TLS.ServerName != "") {
return errors.New("mysql: TLS fields cannot be used when disabled")
return configdiag.New("tls.mode", "must be required when TLS fields are configured")
}
if c.Socket != "" && c.TLS.Mode != "disabled" && c.TLS.ServerName == "" {
return errors.New("mysql: TLS over a unix socket requires serverName")
return configdiag.New("tls.serverName", "is required for TLS over a unix socket")
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"database/sql"
"database/sql/driver"
"errors"
"net"
"os"
@@ -166,11 +167,64 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
return rollbackErr
}); !errors.Is(err, rollbackErr) {
t.Fatal(err)
} else {
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageWork || txErr.Outcome != database.TxOutcomeRolledBack {
t.Fatal("real MySQL rollback was not classified", err)
}
}
var count int
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check").Scan(&count); err != nil || count != 1 {
t.Fatalf("transaction count=%d err=%v", count, err)
}
// Commit/Rollback run against real MySQL, then a driver-boundary wrapper
// suppresses the acknowledgement. This is deterministic fault injection,
// not a claim to reproduce every wire-level disconnect timing.
for _, loseCommit := range []bool{true, false} {
name, id := "commitAcknowledgementLost", 3
if !loseCommit {
name, id = "rollbackAcknowledgementLost", 4
}
t.Run(name, func(t *testing.T) {
connector, err := mysql.NewConnector(cfg.Clone())
if err != nil {
t.Fatal(err)
}
ackErr, workErr := errors.New("injected lost acknowledgement"), errors.New("work rejected")
fault := &ackLossConnector{Connector: connector, loseCommit: loseCommit, ackErr: ackErr}
faultPool, err := database.OpenConnector(ctx, fault, pool)
if err != nil {
t.Fatal(err)
}
defer faultPool.Close()
err = faultPool.InTx(ctx, nil, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, "INSERT INTO connector_check VALUES (?)", id); err != nil {
return err
}
if !loseCommit {
return workErr
}
return nil
})
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || !errors.Is(err, ackErr) {
t.Fatal("unknown acknowledgement was treated as definite outcome", err)
}
wantCount := 0
if loseCommit {
wantCount = 1
if txErr.Stage != database.TxStageCommit || txErr.Err != ackErr || txErr.RollbackErr != sql.ErrTxDone || fault.commits.Load() != 1 || fault.rollbacks.Load() != 0 {
t.Fatal("commit failure was retried or misclassified", err)
}
} else if txErr.Stage != database.TxStageWork || !errors.Is(err, workErr) || txErr.RollbackErr != ackErr || fault.commits.Load() != 0 || fault.rollbacks.Load() != 1 {
t.Fatal("rollback failure lost its primary/secondary causes", err)
}
var stored int
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check WHERE id = ?", id).Scan(&stored); err != nil || stored != wantCount {
t.Fatal("independent connection observed unexpected persistence", stored, err)
}
})
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
@@ -178,3 +232,58 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
t.Fatal("closing first pool affected second:", err)
}
}
type ackLossConnector struct {
driver.Connector
loseCommit bool
ackErr error
commits, rollbacks atomic.Int32
}
func (c *ackLossConnector) Connect(ctx context.Context) (driver.Conn, error) {
conn, err := c.Connector.Connect(ctx)
if err != nil {
return nil, err
}
return &ackLossConn{Conn: conn, owner: c}, nil
}
type ackLossConn struct {
driver.Conn
owner *ackLossConnector
}
func (c *ackLossConn) Ping(ctx context.Context) error { return c.Conn.(driver.Pinger).Ping(ctx) }
func (c *ackLossConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
tx, err := c.Conn.(driver.ConnBeginTx).BeginTx(ctx, opts)
if err != nil {
return nil, err
}
return &ackLossTx{Tx: tx, owner: c.owner}, nil
}
type ackLossTx struct {
driver.Tx
owner *ackLossConnector
}
func (tx *ackLossTx) Commit() error {
tx.owner.commits.Add(1)
if err := tx.Tx.Commit(); err != nil {
return err
}
if tx.owner.loseCommit {
return tx.owner.ackErr
}
return nil
}
func (tx *ackLossTx) Rollback() error {
tx.owner.rollbacks.Add(1)
if err := tx.Tx.Rollback(); err != nil {
return err
}
if !tx.owner.loseCommit {
return tx.owner.ackErr
}
return nil
}

126
database/transaction.go Normal file
View File

@@ -0,0 +1,126 @@
package database
import (
"context"
"database/sql"
"errors"
)
// TxStage identifies the operation which produced the primary error.
type TxStage string
const (
TxStageBegin TxStage = "begin"
TxStageWork TxStage = "work"
TxStageCommit TxStage = "commit"
)
// TxOutcome describes only what InTx can confirm about its managed transaction.
// An absent/unrecognized value must be treated as unknown, never as rolled back.
type TxOutcome string
const (
TxOutcomeUnknown TxOutcome = "unknown"
TxOutcomeNotStarted TxOutcome = "notStarted"
TxOutcomeRolledBack TxOutcome = "rolledBack"
)
// TxError retains the primary failure and any rollback failure independently.
// Use errors.As to inspect it. errors.Is/As traverse Err first, then RollbackErr;
// inspect Err alone when the distinction between the two causes matters.
// Error text is for internal diagnostics and may include driver/business details.
type TxError struct {
Stage TxStage
Outcome TxOutcome
Err error
RollbackErr error
}
func (e *TxError) Error() string {
if e == nil {
return "transaction error"
}
message := string(e.Stage) + " transaction (" + string(e.Outcome) + ")"
if e.Err != nil {
message += ": " + e.Err.Error()
}
if e.RollbackErr != nil {
message += "; rollback transaction: " + e.RollbackErr.Error()
}
return message
}
// Unwrap preserves both causes, including sql.ErrTxDone. That sentinel means
// the local transaction is finished, not that the database confirmed rollback.
func (e *TxError) Unwrap() []error {
if e == nil {
return nil
}
if e.Err == nil {
if e.RollbackErr == nil {
return nil
}
return []error{e.RollbackErr}
}
if e.RollbackErr == nil {
return []error{e.Err}
}
return []error{e.Err, e.RollbackErr}
}
// InTx begins a transaction, calls work once, and commits only if work returns
// nil. Every returned error is a *TxError; a nil result confirms Commit succeeded.
// Commit failures always have an unknown outcome, even for cancellation or a
// driver error normally associated with a lost connection. InTx does not retry.
//
// work must use only the supplied transaction for transactional operations and
// must not Commit/Rollback it, issue transaction-control/implicit-commit SQL, or
// let it escape to background goroutines. Outcome describes this transaction,
// not files, other connections, nontransactional tables or external side effects.
//
// Panic and runtime.Goexit still attempt rollback and propagate unchanged; they
// do not return a TxError. Rollback errors during those unwinds cannot be returned.
// A nil context is treated as context.Background().
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) error {
if db == nil || db.DB == nil {
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("database is not initialized")}
}
if work == nil {
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("transaction callback is nil")}
}
if ctx == nil {
ctx = context.Background()
}
tx, err := db.BeginTx(ctx, options)
if err != nil {
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: err}
}
finished := false
defer func() {
if !finished {
// Only the panic/Goexit path reaches here without explicit cleanup.
_ = tx.Rollback()
}
}()
if err = work(tx); err != nil {
failure := txFailure(tx, TxStageWork, err)
finished = true
return failure
}
if err = tx.Commit(); err != nil {
failure := txFailure(tx, TxStageCommit, err)
finished = true
return failure
}
finished = true
return nil
}
func txFailure(tx *sql.Tx, stage TxStage, cause error) *TxError {
rollbackErr := tx.Rollback()
outcome := TxOutcomeUnknown
if stage == TxStageWork && rollbackErr == nil {
outcome = TxOutcomeRolledBack
}
return &TxError{Stage: stage, Outcome: outcome, Err: cause, RollbackErr: rollbackErr}
}

View File

@@ -0,0 +1,56 @@
package database_test
import (
"database/sql"
"encoding/json"
"errors"
"net/http/httptest"
"strings"
"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestInTxPublicErrorCompatibility(t *testing.T) {
private := errors.New("private database details")
for _, scenario := range []string{"business", "businessAndRollback", "commit"} {
t.Run(scenario, func(t *testing.T) {
f := &txFixture{}
if scenario == "businessAndRollback" {
f.rollbackErr = private
} else if scenario == "commit" {
f.commitErr = private
}
pool := txPool(t, f)
app := omni.New(omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}),
omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
"INVALID": {Code: 400002, Message: "{field} is invalid", Params: []apierrors.Parameter{{Name: "field", Type: "string"}}},
}}))
defer app.Close()
app.POST("/record", func(c *omni.Context) error {
return pool.InTx(c.Context(), nil, func(*sql.Tx) error {
if scenario == "commit" {
return nil
}
return apierrors.ByCode("INVALID").WithParams(map[string]any{"field": "name"}).WithCause(private)
})
})
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("POST", "/record", nil))
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
wantStatus, wantCode, wantMessage := 200, float64(400002), "name is invalid"
if scenario == "commit" {
wantStatus, wantCode, wantMessage = 500, 9999, "internal server error"
}
data, hasData := body["data"]
if w.Code != wantStatus || body["code"] != wantCode || body["msg"] != wantMessage || !hasData || data != nil || strings.Contains(w.Body.String(), "private") {
t.Fatal("transaction wrapper changed public error response:", w.Code, w.Body.String())
}
})
}
}

View File

@@ -0,0 +1,441 @@
package database_test
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/database"
)
// The driver can persist a write and lose the commit acknowledgement, or fail
// before persisting it. Both must be indistinguishable to the generic wrapper.
type txFixture struct {
beginErr, commitErr, rollbackErr error
persistBeforeError bool
onBegin func(context.Context, driver.TxOptions)
onCommit func()
onRollback func()
begins, commits, rollbacks, saved atomic.Int64
}
func (f *txFixture) Connect(context.Context) (driver.Conn, error) { return &txConn{owner: f}, nil }
func (f *txFixture) Driver() driver.Driver { return f }
func (f *txFixture) Open(string) (driver.Conn, error) { return f.Connect(context.Background()) }
type txConn struct {
owner *txFixture
pending int64
}
func (c *txConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not used") }
func (c *txConn) Close() error { return nil }
func (c *txConn) Begin() (driver.Tx, error) {
return c.BeginTx(context.Background(), driver.TxOptions{})
}
func (c *txConn) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) {
c.owner.begins.Add(1)
if c.owner.onBegin != nil {
c.owner.onBegin(ctx, options)
}
if c.owner.beginErr != nil {
return nil, c.owner.beginErr
}
c.pending = 0
return &txDriver{conn: c}, nil
}
func (c *txConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
c.pending++
return driver.RowsAffected(1), nil
}
type txDriver struct{ conn *txConn }
func (tx *txDriver) Commit() error {
f := tx.conn.owner
f.commits.Add(1)
if f.commitErr == nil || f.persistBeforeError {
f.saved.Add(tx.conn.pending)
}
if f.onCommit != nil {
f.onCommit()
}
return f.commitErr
}
func (tx *txDriver) Rollback() error {
f := tx.conn.owner
f.rollbacks.Add(1)
if f.onRollback != nil {
f.onRollback()
}
return f.rollbackErr
}
func txPool(t testing.TB, f *txFixture) *database.DB {
t.Helper()
pool := sql.OpenDB(f)
pool.SetMaxOpenConns(8)
t.Cleanup(func() { _ = pool.Close() })
return &database.DB{DB: pool}
}
type txCause struct{ name string }
func (e *txCause) Error() string { return e.name }
func checkTxError(t *testing.T, err error, stage database.TxStage, outcome database.TxOutcome, cause, rollback error) *database.TxError {
t.Helper()
var txErr *database.TxError
if !errors.As(fmt.Errorf("repository: %w", err), &txErr) {
t.Fatalf("missing typed transaction error: %v", err)
}
if txErr.Stage != stage || txErr.Outcome != outcome || txErr.Err != cause || txErr.RollbackErr != rollback {
t.Fatalf("unexpected transaction error: %+v", txErr)
}
if cause != nil && !errors.Is(err, cause) || rollback != nil && !errors.Is(err, rollback) {
t.Fatalf("cause chain lost: %v", err)
}
return txErr
}
func TestInTxStagesAndCauses(t *testing.T) {
primary, rollback := &txCause{"primary"}, &txCause{"rollback"}
for _, tc := range []struct {
name string
beginErr, workErr, commitErr, rb error
stage database.TxStage
outcome database.TxOutcome
wantWork, wantCommit, wantRB int64
}{
{"begin", primary, nil, nil, nil, database.TxStageBegin, database.TxOutcomeNotStarted, 0, 0, 0},
{"work", nil, primary, nil, nil, database.TxStageWork, database.TxOutcomeRolledBack, 1, 0, 1},
{"workAndRollback", nil, primary, nil, rollback, database.TxStageWork, database.TxOutcomeUnknown, 1, 0, 1},
{"commit", nil, nil, primary, nil, database.TxStageCommit, database.TxOutcomeUnknown, 1, 1, 0},
} {
t.Run(tc.name, func(t *testing.T) {
f := &txFixture{beginErr: tc.beginErr, commitErr: tc.commitErr, rollbackErr: tc.rb}
pool := txPool(t, f)
var calls int64
err := pool.InTx(context.Background(), nil, func(tx *sql.Tx) error {
calls++
_, err := tx.ExecContext(context.Background(), "write")
if err != nil {
t.Fatal(err)
}
return tc.workErr
})
rb := tc.rb
if tc.stage == database.TxStageCommit {
rb = sql.ErrTxDone // No driver rollback happened after Commit.
}
checkTxError(t, err, tc.stage, tc.outcome, primary, rb)
var typedCause *txCause
if !errors.As(err, &typedCause) || typedCause != primary {
t.Fatal("primary typed cause not first in chain")
}
if calls != tc.wantWork || f.commits.Load() != tc.wantCommit || f.rollbacks.Load() != tc.wantRB || f.saved.Load() != 0 {
t.Fatal("unexpected callback/commit/rollback calls or persisted write")
}
})
}
t.Run("rollbackTypedCause", func(t *testing.T) {
pool := txPool(t, &txFixture{rollbackErr: rollback})
cause := errors.New("work failed")
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
var found *txCause
if !errors.As(err, &found) || found != rollback {
t.Fatal("typed rollback cause lost")
}
})
}
func TestInTxCommitOutcomeUnknown(t *testing.T) {
for _, cause := range []error{&txCause{"lost acknowledgement"}, context.DeadlineExceeded, driver.ErrBadConn} {
for _, persisted := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/persisted=%t", cause, persisted), func(t *testing.T) {
f := &txFixture{commitErr: cause, persistBeforeError: persisted}
pool := txPool(t, f)
calls := 0
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
calls++
_, err := tx.ExecContext(context.Background(), "register object")
return err
})
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
wantSaved := int64(0)
if persisted {
wantSaved = 1
}
if f.saved.Load() != wantSaved || calls != 1 || f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
t.Fatal("commit was retried, rolled back, or incorrectly modeled")
}
})
}
}
}
func TestInTxCancellationAndOwnership(t *testing.T) {
t.Run("beforeBegin", func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := pool.InTx(ctx, nil, func(*sql.Tx) error { t.Fatal("work called"); return nil })
checkTxError(t, err, database.TxStageBegin, database.TxOutcomeNotStarted, context.Canceled, nil)
if f.begins.Load() != 0 {
t.Fatal("driver began a canceled transaction")
}
})
for _, returnedWorkError := range []bool{false, true} {
t.Run(fmt.Sprintf("automaticRollback/workError=%t", returnedWorkError), func(t *testing.T) {
rolledBack := make(chan struct{})
rollbackCause := errors.New("rollback connection lost")
f := &txFixture{rollbackErr: rollbackCause, onRollback: func() { close(rolledBack) }}
pool := txPool(t, f)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
cancel()
select {
case <-rolledBack:
case <-time.After(3 * time.Second):
t.Fatal("automatic rollback did not run")
}
if returnedWorkError {
return context.Canceled
}
return nil
})
stage, primary := database.TxStageCommit, error(sql.ErrTxDone)
if returnedWorkError {
stage, primary = database.TxStageWork, context.Canceled
}
checkTxError(t, err, stage, database.TxOutcomeUnknown, primary, sql.ErrTxDone)
// database/sql owns this asynchronous rollback and does not expose
// its driver error. Do not pretend ErrTxDone means successful rollback.
if errors.Is(err, rollbackCause) || f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
t.Fatal("unexpected automatic rollback accounting")
}
})
}
t.Run("cancelDuringCommit", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &txFixture{commitErr: context.Canceled, persistBeforeError: true, onCommit: cancel}
pool := txPool(t, f)
err := pool.InTx(ctx, nil, func(tx *sql.Tx) error { _, err := tx.ExecContext(ctx, "write"); return err })
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, context.Canceled, sql.ErrTxDone)
if f.saved.Load() != 1 || f.rollbacks.Load() != 0 {
t.Fatal("cancellation hid a committed write")
}
})
for _, action := range []string{"commit", "rollback"} {
t.Run("manual/"+action, func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
cause := errors.New("callback failed")
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
var err error
if action == "commit" {
err = tx.Commit()
} else {
err = tx.Rollback()
}
if err != nil {
t.Fatal(err)
}
return cause
})
checkTxError(t, err, database.TxStageWork, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
})
}
}
func TestInTxSuccessPanicAndConcurrency(t *testing.T) {
t.Run("success", func(t *testing.T) {
type key struct{}
ctx := context.WithValue(context.Background(), key{}, "value")
f := &txFixture{onBegin: func(received context.Context, opts driver.TxOptions) {
if received.Value(key{}) != "value" || !opts.ReadOnly || opts.Isolation != driver.IsolationLevel(sql.LevelSerializable) {
t.Error("context or options changed")
}
}}
pool := txPool(t, f)
if err := pool.InTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: true}, func(*sql.Tx) error { return nil }); err != nil {
t.Fatal(err)
}
if f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
t.Fatal("unexpected successful transaction calls")
}
})
for _, rbErr := range []error{nil, errors.New("rollback failed during unwind")} {
t.Run(fmt.Sprintf("panic/%v", rbErr), func(t *testing.T) {
f := &txFixture{rollbackErr: rbErr}
pool := txPool(t, f)
value := &struct{ text string }{"original panic"}
func() {
defer func() {
if recover() != value {
t.Error("panic identity lost")
}
}()
_ = pool.InTx(nil, nil, func(*sql.Tx) error { panic(value) })
}()
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
t.Fatal("panic did not roll back exactly once")
}
})
}
t.Run("goexit", func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
done := make(chan struct{})
go func() {
defer close(done)
_ = pool.InTx(nil, nil, func(*sql.Tx) error { runtime.Goexit(); return nil })
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Goexit did not finish")
}
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
t.Fatal("Goexit did not roll back exactly once")
}
})
t.Run("concurrent", func(t *testing.T) {
f := &txFixture{}
pool := txPool(t, f)
var group sync.WaitGroup
for i := 0; i < 64; i++ {
group.Add(1)
go func(i int) {
defer group.Done()
cause := &txCause{fmt.Sprint(i)}
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Err != cause || txErr.Outcome != database.TxOutcomeRolledBack || !errors.Is(err, cause) {
t.Error("cross-request error state")
}
}(i)
}
group.Wait()
if f.rollbacks.Load() != 64 || f.commits.Load() != 0 {
t.Fatal("incorrect concurrent calls")
}
})
}
func TestInTxPreconditionsAndNestedErrors(t *testing.T) {
for _, pool := range []*database.DB{nil, {}} {
err := pool.InTx(nil, nil, func(*sql.Tx) error { t.Fatal("unexpected callback"); return nil })
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || txErr.Outcome != database.TxOutcomeNotStarted || txErr.Err == nil {
t.Fatal("uninitialized database was not classified")
}
}
f := &txFixture{}
pool := txPool(t, f)
err := pool.InTx(nil, nil, nil)
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || f.begins.Load() != 0 {
t.Fatal("nil callback began a transaction")
}
inner := &database.TxError{Stage: database.TxStageCommit, Outcome: database.TxOutcomeUnknown, Err: errors.New("other transaction")}
err = pool.InTx(nil, nil, func(*sql.Tx) error { return inner })
outer := checkTxError(t, err, database.TxStageWork, database.TxOutcomeRolledBack, inner, nil)
if outer == inner || !errors.As(outer.Err, &txErr) || txErr != inner {
t.Fatal("nested error was flattened")
}
// Empty or typed-nil error values must not panic or expose nil unwrap entries.
for _, value := range []*database.TxError{nil, {}, {RollbackErr: inner}} {
_ = value.Error()
for _, child := range value.Unwrap() {
if child == nil {
t.Fatal("nil unwrap child")
}
}
}
}
func TestInTxCancellationRace(t *testing.T) {
const count = 128
rollbackCause := errors.New("rollback acknowledgement unavailable")
var rollbacks sync.WaitGroup
rollbacks.Add(count)
f := &txFixture{rollbackErr: rollbackCause, onRollback: rollbacks.Done}
pool := txPool(t, f)
for i := 0; i < count; i++ {
ctx, cancel := context.WithCancel(context.Background())
workFailure := i%2 == 0
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
cancel()
if i%3 == 0 {
runtime.Gosched() // Let the automatic rollback race explicit cleanup.
}
if workFailure {
return context.Canceled
}
return nil
})
cancel()
var txErr *database.TxError
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || f.commits.Load() != 0 {
t.Fatal("cancellation was treated as confirmed rollback", err)
}
wantStage := database.TxStageCommit
if workFailure {
wantStage = database.TxStageWork
}
if txErr.Stage != wantStage || txErr.RollbackErr != rollbackCause && txErr.RollbackErr != sql.ErrTxDone {
t.Fatal("unexpected cancellation diagnostics", err)
}
if txErr.RollbackErr == rollbackCause && !errors.Is(err, rollbackCause) {
t.Fatal("observed rollback failure lost")
}
}
done := make(chan struct{})
go func() { rollbacks.Wait(); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("canceled transactions did not finish rollback")
}
}
func BenchmarkInTx(b *testing.B) {
cause := errors.New("example failure")
for _, name := range []string{"success", "beginFailure", "workFailure", "rollbackFailure", "commitUnknown"} {
b.Run(name, func(b *testing.B) {
f := &txFixture{}
var workErr error
switch name {
case "beginFailure":
f.beginErr = cause
case "workFailure":
workErr = cause
case "rollbackFailure":
workErr, f.rollbackErr = cause, cause
case "commitUnknown":
f.commitErr = cause
}
pool := txPool(b, f)
work := func(*sql.Tx) error { return workErr }
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := pool.InTx(context.Background(), nil, work)
if (err == nil) != (name == "success") {
b.Fatal(err)
}
}
})
}
}

View File

@@ -10,11 +10,13 @@ DEMO_CLIENT_TIMEOUT=500ms DEMO_MAX_ITEMS=0 go run ./examples/configuration
DEMO_MAX_ITEMS=wrong go run ./examples/configuration
```
前三次都输出 `timeout=2s maxItems=100 allowed=[json xml]`。环境覆盖允许显式零值。
前三次都输出 `timeout=2s maxItems=100 allowed=[json xml]`。文件显式引用允许零值。
最后一条输出定位 `maxItems` / `DEMO_MAX_ITEMS` 的安全错误,不打印输入原值。
也可 `-config /path/to/app.yaml` 读取自己的文件。示例没有建立网络连接。
`Config` 定义字段,`envDefault` 定义缺省值,`Validate` 用 `config.Invalid` 返回字段与公开原因。
`Config` 定义字段,`Default()` 定义初始值,`Validate` 用 `config.Invalid` 返回字段与公开原因。
文件中的 `${NAME}` 必填,`${NAME:-value}` 支持空/未设置的备用值,`$${NAME}` 保留字面量;字符串不裁剪,也不二次展开。没有出现在文件引用中的变量不会被读取。
`newLoader` 额外注册 `.custom`(演示复用 JSON decoder);同一个 `load` 可读取所有格式。
JSON/YAML/XML 直接可用,无需注册。新增格式只修改解析器注册,不改业务结构或校验。

View File

@@ -1,5 +1 @@
{
"client": {"endpoint": "https://example.com", "timeout": "2s"},
"maxItems": 100,
"allowed": ["json", "xml"]
}
{"client":{"endpoint":"https://${DEMO_HOST:-example.com}","timeout":"${DEMO_CLIENT_TIMEOUT:-2s}","token":"${DEMO_CLIENT_TOKEN:-}"},"maxItems":"${DEMO_MAX_ITEMS:-100}","allowed":["json","xml"]}

View File

@@ -1,8 +1 @@
<config>
<client>
<endpoint>https://example.com</endpoint>
<timeout>2s</timeout>
</client>
<maxItems>100</maxItems>
<allowed type="array"><item>json</item><item>xml</item></allowed>
</config>
<config><client><endpoint>https://${DEMO_HOST:-example.com}</endpoint><timeout>${DEMO_CLIENT_TIMEOUT:-2s}</timeout><token>${DEMO_CLIENT_TOKEN:-}</token></client><maxItems>${DEMO_MAX_ITEMS:-100}</maxItems><allowed type="array"><item>json</item><item>xml</item></allowed></config>

View File

@@ -1,5 +1,6 @@
client:
endpoint: https://example.com
timeout: 2s
maxItems: 100
endpoint: "https://${DEMO_HOST:-example.com}"
timeout: "${DEMO_CLIENT_TIMEOUT:-2s}"
token: "${DEMO_CLIENT_TOKEN:-}"
maxItems: "${DEMO_MAX_ITEMS:-100}"
allowed: [json, xml]

View File

@@ -17,16 +17,18 @@ var examples embed.FS
type ClientConfig struct {
Endpoint string `json:"endpoint"`
Timeout time.Duration `json:"timeout" envDefault:"3s"`
// This field is deliberately unavailable to configuration files.
Token string `json:"-" env:"DEMO_CLIENT_TOKEN,secret"`
Timeout time.Duration `json:"timeout"`
// The file chooses a fixed value or an explicit environment reference.
Token string `json:"token"`
}
type Config struct {
Client ClientConfig `json:"client"`
MaxItems int `json:"maxItems" envDefault:"20"`
MaxItems int `json:"maxItems"`
Allowed []string `json:"allowed"`
}
func Default() Config { return Config{Client: ClientConfig{Timeout: 3 * time.Second}, MaxItems: 20} }
func (c Config) Validate() error {
if c.Client.Timeout <= 0 {
return config.Invalid("client.timeout", "must be positive")
@@ -48,7 +50,7 @@ func load(path string) (Config, error) {
if err != nil {
return Config{}, err
}
return config.LoadProject(path, Config{}, config.EnvOptions{Prefix: "DEMO_", Empty: "reject"}, loader)
return config.LoadProject(path, Default(), loader)
}
func run() error {
path := flag.String("config", "", "external configuration file")

17
examples/cors/README.md Normal file
View File

@@ -0,0 +1,17 @@
# CORS 与正常鉴权
在框架根目录运行:
```sh
go run ./examples/cors
WEB_ORIGIN=https://admin.example go run ./examples/cors
```
仅监听临时 localhost 端口,不依赖数据库或外部服务。配置由统一 LoadProject 加载,示例模拟浏览器 Origin/预检请求头,依次检查:
- OPTIONS 预检返回 204,无需登录。
- 实际 GET 未登录仍返回 401,前端可读取这条错误响应。
- 示例凭据通过后返回 200。
- 404 也保留允许来源的 CORS 响应头。
示例 Token 仅用于展示,项目使用自身鉴权实现。CORS 不验证 Token,也不保证其他客户端不能调用 API。完整规则与配置见 [cors](../../cors/README.md)。

10
examples/cors/config.yaml Normal file
View File

@@ -0,0 +1,10 @@
server:
cors:
enabled: true
allowedOrigins:
- "${WEB_ORIGIN:-https://web.example}"
allowedMethods: [GET, POST]
allowedHeaders: [Authorization, Content-Type]
exposedHeaders: [X-Request-ID]
allowCredentials: false
maxAge: 10m

76
examples/cors/main.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/config"
)
type Config struct {
Server config.Config `json:"server"`
}
func (c Config) Validate() error { return config.AtField("server", c.Server.Validate()) }
func run(path string) error {
cfg, err := config.LoadProject(path, Config{Server: config.Default()})
if err != nil {
return err
}
a := omni.New(omni.WithConfig(cfg.Server))
defer a.Close()
a.GET("/private", func(c *omni.Context) error {
if c.Header("Authorization") != "Bearer example-only" {
return omni.Unauthorized("LOGIN_REQUIRED", "login required")
}
return c.OK("authenticated")
})
server := httptest.NewServer(a)
defer server.Close()
client := &http.Client{Timeout: 3 * time.Second}
defer client.CloseIdleConnections()
for _, tc := range []struct {
method, path, auth string
status int
}{{"OPTIONS", "/private", "", 204}, {"GET", "/private", "", 401}, {"GET", "/private", "Bearer example-only", 200}, {"GET", "/missing", "", 404}} {
r, _ := http.NewRequest(tc.method, server.URL+tc.path, nil)
r.Header.Set("Origin", cfg.Server.CORS.AllowedOrigins[0])
if tc.method == "OPTIONS" {
r.Header.Set("Access-Control-Request-Method", "GET")
r.Header.Set("Access-Control-Request-Headers", "authorization")
}
if tc.auth != "" {
r.Header.Set("Authorization", tc.auth)
}
res, err := client.Do(r)
if err != nil {
return err
}
_, readErr := io.Copy(io.Discard, res.Body)
closeErr := res.Body.Close()
if err := errors.Join(readErr, closeErr); err != nil {
return err
}
if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") == "" {
return fmt.Errorf("unexpected CORS response: %d", res.StatusCode)
}
fmt.Printf("%s %s -> %d, CORS allowed\n", tc.method, tc.path, res.StatusCode)
}
return nil
}
func main() {
path := flag.String("config", "examples/cors/config.yaml", "selected configuration file")
flag.Parse()
if err := run(*path); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,18 @@
# 配置诊断、严格 HTTP 状态、类型预检和日志时区
在框架根目录运行,不需要数据库、账号或外部服务。HTTP 示例只访问临时 localhost 服务:
```sh
go run ./examples/developerTools
DEMO_LOG_TIME_ZONE=UTC go run ./examples/developerTools
# 以下两条应非零退出,输出具体字段和环境变量名,不输出原值:
DEMO_DB_PORT=70000 go run ./examples/developerTools
DEMO_LOG_LEVEL=invalid go run ./examples/developerTools
```
- 配置只做校验,没有连接数据库。失败指向 `database.port` / `server.log.level`,并保留文件行列与变量名。
- Logger 使用选定时区;只改变记录的时间戳,不修改全局 time.Local 和业务时间字段。
- `copyx.Check[Input, Result]` 发现切片元素内的不兼容字段;加上显式 `strconv.Atoi` converter 后通过。预检不执行 converter,真实非法数字仍在 Copy 时返回错误。
- JSON 调用显式 `WithSuccessStatuses(200)`,拒绝 201,框架仍负责关闭响应体。
详见 [配置](../../CONFIG.md)、[日志](../../logx/README.md)、[HTTP 客户端](../../httpc/README.md)、[copyx](../../COPY.md)。

View File

@@ -0,0 +1,7 @@
# Validates database settings without connecting in this demonstration.
database:
port: "${DEMO_DB_PORT:-3306}"
server:
log:
level: "${DEMO_LOG_LEVEL:-info}"
timeZone: "${DEMO_LOG_TIME_ZONE:-Asia/Shanghai}"

View File

@@ -0,0 +1,104 @@
// Run from the framework root with: go run ./examples/developerTools
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
_ "time/tzdata" // This example embeds zones; production builds can use -tags timetzdata.
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/copyx"
mysql "git.yuntuo.hk/k2safe/go-omni/database/mysql"
"git.yuntuo.hk/k2safe/go-omni/httpc"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
type Config struct {
Database mysql.Config `json:"database"`
Server config.Config `json:"server"`
}
func Default() Config {
c := config.Default()
c.Log.Output = "stdout"
return Config{Database: mysql.DefaultConfig(), Server: c}
}
func (c Config) Validate() error {
if err := c.Database.Validate(); err != nil {
return config.AtField("database", err)
}
return config.AtField("server", c.Server.Validate())
}
type Input struct{ Values []TextValue }
type TextValue struct{ Number string }
type Result struct{ Values []NumberValue }
type NumberValue struct{ Number int }
func run(path string) error {
cfg, err := config.LoadProject(path, Default())
if err != nil {
return err
}
// Validation only: this example never opens a database.
logger, err := logx.New(cfg.Server.Log)
if err != nil {
return err
}
defer logger.Close()
logger.InfoContext(context.Background(), "instance time zone", "configuredZone", cfg.Server.Log.TimeZone)
if err := copyx.Check[Input, Result](); !errors.Is(err, copyx.ErrType) {
return fmt.Errorf("expected a type mismatch, got %v", err)
}
// Explicit Go converter owns the number conversion; no JSON mapping file.
options := []copyx.Option{copyx.WithConverter(strconv.Atoi)}
if err := copyx.Check[Input, Result](options...); err != nil {
return err
}
var output Result
if err := copyx.Copy(&output, Input{Values: []TextValue{{Number: "42"}}}, options...); err != nil {
return err
}
fmt.Printf("type check and copy succeeded: %d\n", output.Values[0].Number)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/created" {
w.WriteHeader(http.StatusCreated)
}
io.WriteString(w, `{"value":"example"}`)
}))
defer server.Close()
client, err := httpc.New(httpc.Config{DisableLogging: true})
if err != nil {
return err
}
defer client.Close()
var reply struct {
Value string `json:"value"`
}
if err := client.JSON(context.Background(), http.MethodGet, server.URL, nil, &reply, httpc.WithSuccessStatuses(http.StatusOK)); err != nil {
return err
}
err = client.JSON(context.Background(), http.MethodGet, server.URL+"/created", nil, &reply, httpc.WithSuccessStatuses(http.StatusOK))
var status *httpc.StatusError
if !errors.As(err, &status) || status.StatusCode != http.StatusCreated {
return fmt.Errorf("expected status rejection, got %v", err)
}
fmt.Println("strict JSON: 200 accepted, 201 rejected")
return nil
}
func main() {
path := flag.String("config", "examples/developerTools/config.yaml", "selected configuration file")
flag.Parse()
if err := run(*path); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,12 @@
# HTTP Client 示例
```sh
go run ./examples/httpClient
```
只创建本机临时 HTTP 服务,不访问业务系统、数据库或公网;结束后关闭连接和监听器。
示例创建并复用一个 Client,显式直连,配置连接池/阶段超时,通过 JSON 选项传入 Authorization 和 Accept,并按单次请求收紧响应上限。
输出 `authenticated request succeeded`。示例 token 是固定演示字符串,实际项目从配置或请求上下文取得认证信息。
完整参数、代理规则、流式调用、生命周期、重试边界和兼容说明见 [httpc](../../httpc/README.md)。

View File

@@ -0,0 +1,57 @@
// Run with go run ./examples/httpClient. All HTTP traffic stays on loopback.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"time"
"git.yuntuo.hk/k2safe/go-omni/httpc"
)
type result struct {
Message string `json:"message"`
}
func run() error {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer example-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result{Message: "authenticated request succeeded"})
}))
defer upstream.Close()
client, err := httpc.New(httpc.Config{
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
Timeout: 3 * time.Second, DialTimeout: time.Second, TLSHandshakeTimeout: time.Second,
ResponseHeaderTimeout: time.Second, IdleConnTimeout: 30 * time.Second,
MaxConnsPerHost: 16, MaxIdleConnsPerHost: 8, MaxIdleConns: 32,
MaxResponseBytes: 1 << 20, DisableLogging: true,
})
if err != nil {
return err
}
defer client.Close()
var output result
if err = client.JSON(context.Background(), http.MethodGet, upstream.URL, nil, &output,
httpc.WithHeader("Authorization", "Bearer example-token"),
httpc.WithHeaders(http.Header{"Accept": []string{"application/json"}}),
httpc.WithMaxResponseBytes(64<<10),
); err != nil {
return err
}
fmt.Println(output.Message)
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestExample(t *testing.T) {
if err := run(); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,6 +1,6 @@
{
"generatorConfig": true,
"templateVersion": "v0.20.0-preview",
"templateVersion": "v0.22.0-preview",
"profile": {
"database": "none",
"storage": "none",
@@ -12,7 +12,7 @@
"entry": "api/managed.api",
"generated": {
"docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
"internal/config/loadGen.go": "5d630b894abdbbd66ddfa7d7d30f7573fc4ff995020a32a9f9157cd4af3a5655",
"internal/config/loadGen.go": "d85e3123e4c96c95cb0886e60e4760230426efca4d2bbbfc981822da08b90d13",
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
"internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
@@ -22,7 +22,7 @@
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
"main.go": "64e26d97de7e7c6c17f47186785edc73d43c23d6c20b50bfc7fff6ef2e914f17"
"main.go": "10cb6a84b2eb1b68dc10c2dca949c9dd7c68a9f78a8e6d8f56ce9336d4b2d125"
},
"contract": {
"routes": {

View File

@@ -46,6 +46,6 @@ Configuration, API contracts, svc fields/Init/AppOptions, Logic, Repository and
middleware implementations remain developer-owned. Regeneration preserves them.
Use `go-omni api format` to format local contracts, then `api generate` and
`api generate --check`. See the [framework development guide](../../DEVELOPMENT.md)
for array/null responses, typed multipart, full configuration environment overrides,
for array/null responses, typed multipart, explicit configuration references and -check-config,
shared contracts and v0.13 upgrade steps. This example keeps its raw callback,
streaming upload and download to exercise those existing extension points.

Some files were not shown because too many files have changed in this diff Show More