Compare commits
6 Commits
v0.25.0-pr
...
v0.28.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b6c0043b6 | |||
| f8e0dcc5a6 | |||
| 3b00bb55d1 | |||
| 859f1851b1 | |||
| d1a747c36f | |||
| 6a95cad184 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## v0.27.0-preview(2026-09-29)
|
||||
|
||||
- HTTP 配置新增 defaultMaxBodyBytes、maxBodyBytes、shutdownCancelTimeout;三种内置服务统一默认/路由/硬请求体限额,配置冲突在监听前报告完整路由与限额。native/HTTP2 不再以隐含 8 MiB 阻挡已放宽的 App 上传路由。
|
||||
- App 在所有服务入口跟踪完整请求处理链;关闭连接后有界等待处理器实际退出。超时仍有请求则返回 ErrResourcesRetained,跳过 App 清理;Runtime.Finish 继续停止后台工作但保留依赖,生成入口保留日志。
|
||||
- native 提前拒绝上传后有界半关闭连接,减少 TCP reset 吞掉错误响应;业务 HTTP 200 策略不再触发对被拒绝请求体的继续读取。
|
||||
- HTTP/2 Shutdown 同时等待连接和独立处理器,修复客户端断开后误判完成。增加真实 TLS/流式上传、取消收尾、拒绝晚到请求、资源保留和生成消费者回归,以及 httpSafety 示例。
|
||||
- 迁移:nethttp 手写普通路由新增默认 8 MiB 请求体上限,大请求显式覆盖;生成契约默认 1 MiB 保持。HTTP 停机多一个默认 5s 取消收尾窗口;外部宿主须将运行错误传给 Runtime.Finish,并遵守资源保留信号。
|
||||
|
||||
## v0.26.0-preview(2026-09-29)
|
||||
|
||||
- 新增独立 i18n:不可变命名空间词典、源/默认/启用语言、BCP 47 匹配、显式回退、参数/条件/基数复数和有界资源加载;不依赖 HTTP、业务或数据库。
|
||||
- Go/浏览器共用固定 CLDR 46.1 复数规则;JSON 资源版本、类型导出和按命名空间选择的语言包独立于业务错误格式。
|
||||
- 新增可选 httpi18n/errori18n 适配;错误 translation 元数据、PublicMessage 展示入口,保留原业务码/状态/失败语义/原始原因和首次幂等响应。
|
||||
- 新增 i18n check/export/types/extract;静态与动态键诊断、未知字段/重复定义/参数校验、显式覆盖;输入只读,不执行任意项目 Go。
|
||||
- 新增无前端框架依赖的浏览器模块、按需加载/切换竞态/失败保留、CLI/HTTP/表单示例及通用验收。旧项目默认行为不变。
|
||||
- 新 translation 生成代码要求 v0.26.0-preview 或具备能力的本地源码,未使用该元数据的模板最低版本保持 v0.22。
|
||||
|
||||
- 修复 JSON 对象/数组结束定界符读取失败时丢失原始错误的问题,保留请求体限额的 HTTP 413;共享 Go 目录测试工程改用声明支持的 Go 1.22。
|
||||
|
||||
## v0.25.0-preview(2026-09-26)
|
||||
|
||||
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
|
||||
|
||||
@@ -190,6 +190,10 @@ Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量
|
||||
|
||||
低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。
|
||||
|
||||
## 国际化配置
|
||||
|
||||
通用国际化的 `i18n.Config` 可作为项目配置的字段显式加载并调用 Validate。正文词典使用 `i18n.LoadFS` / `DecodeJSON`,不经过环境引用展开。构建 Bundle 后通过 `WithLanguagePolicy` / `WithErrorLocalizer` 装配借用的只读实例;没有隐式环境变量或全局当前语言。见 [国际化指南](i18n/README.md) 和 [完整示例](examples/i18n/README.md)。
|
||||
|
||||
## 跨域配置
|
||||
|
||||
生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 通用业务开发指南(v0.25.0-preview)
|
||||
# 通用业务开发指南(v0.27.0-preview)
|
||||
|
||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||
|
||||
|
||||
81
PROGRAM.md
Normal file
81
PROGRAM.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 通用 Go 项目模板
|
||||
|
||||
从 v0.28.0-preview 起,`project new/generate/check` 提供不绑定协议与业务的工程骨架。默认只有配置、日志、依赖装配、生命周期和普通 Go 运行入口;不生成 `.api`、HTTP Handler、Swagger、业务任务,也不监听端口、连接数据库或队列。不增加第三方依赖。
|
||||
|
||||
## 创建与运行
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||
go-omni project new demo --module example.com/demo
|
||||
cd demo
|
||||
go mod tidy
|
||||
go run . -check-config
|
||||
go run . # 默认等待 SIGINT / SIGTERM
|
||||
go build -o program .
|
||||
```
|
||||
|
||||
目标目录必须为空;未指定 module 时使用目录名。远程安装的 CLI 将自己的固定版本写入 go.mod,无本地替换。当前通用模板要求框架 v0.28.0-preview 或更新版本。源码开发可显式 `--framework-dir /path/to/go-omni`,不会从当前工作目录隐式选择本地框架。`--framework-version` 可指定兼容版本。框架版本、依赖更新由项目修改 go.mod,重新生成不会代为升级依赖。
|
||||
|
||||
常驻程序、一次性程序用同一份骨架:项目自己的 `Run` 决定何时完成。完整生成项目见 [examples/program](examples/program/README.md),不需要外部服务。
|
||||
|
||||
## 文件职责
|
||||
|
||||
| 路径 | 维护方与职责 |
|
||||
| --- | --- |
|
||||
| `main.go` | 生成器:信号、启动、运行入口监督、退出状态与日志收尾 |
|
||||
| `internal/config/loadGen.go` | 生成器:统一配置加载 |
|
||||
| `internal/svc/runtimeGen.go` | 生成器:依赖初始化及失败收尾 |
|
||||
| `internal/config/config.go` | 项目:Config、Default、Validate;初始只含 log/lifecycle |
|
||||
| `internal/config/loader.go` | 项目:按需注册额外文件格式 |
|
||||
| `etc/config.yaml` | 项目:配置与显式环境引用 |
|
||||
| `internal/svc/serviceContext.go` | 项目:依赖字段、Init、资源与后台工作登记 |
|
||||
| `internal/run/run.go` | 项目:Run 的实际实现 |
|
||||
| `go.mod`、README、.gitignore、其他 Go 文件 | 项目:生成器不覆盖 |
|
||||
| `.goOmni/projectManifest.json` | 生成器:工程身份、模板版本、生成文件哈希;应提交 Git |
|
||||
|
||||
配置遵守 [CONFIG.md](CONFIG.md):默认值 → 文件显式 `${NAME}` / `${NAME:-fallback}` → 类型绑定 → 校验,没有隐式环境覆盖。JSON/YAML/XML 内置;`-config ""` 只用默认值。`-check-config` 不打开日志文件、不初始化依赖、不调用 Run。日志通过独立实例传给 svc,并注入 Context/WorkContext;不替换进程级默认日志实例。
|
||||
|
||||
## 运行与退出
|
||||
|
||||
```go
|
||||
func Run(ctx context.Context, deps *svc.ServiceContext) error {
|
||||
// 一次性程序可完成处理后直接 return nil。
|
||||
// 常驻程序收到 ctx.Done() 后停止自己的主流程。
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
`nil` 表示正常完成,返回 error 表示运行失败。后台组件用 `RequestShutdown(err)` 报告致命故障,`RequestShutdown(nil)` 请求正常停止。框架不替项目重试、重新调度或重启任务。
|
||||
|
||||
依赖在 `svc.Init` 中装配:创建后立即用 `OnClose` 登记清理,后台组件同时登记同名 `OnStop` 和 `OnDrain`。所有登记应在 Init 返回前完成;Run 不得并发追加生命周期登记。组件何时接收工作由项目决定,停止后不得重新开始领取或启动未登记的工作。
|
||||
|
||||
- `Context()` 是提前停止通知,用来停止主流程或接收新工作。
|
||||
- `WorkContext()` 在正常排空期间仍有效,用于已接受的工作。
|
||||
- `OnStop` 停止接收新工作,不关闭处理中的工作仍依赖的资源。
|
||||
- `OnDrain` 返回 nil,必须意味着组件处理、确认和 defer 收尾均已结束。仅返回 `ctx.Err()` 不能证明结束。
|
||||
- 全部停止钩子先于全部排空钩子;同阶段并行。有先后依赖的组件在一个钩子内显式排序。
|
||||
- 全部工作确认结束后,资源按 `OnClose` 登记的反序释放,最后关闭入口拥有的日志。
|
||||
|
||||
生成入口自动监督 Run 本身,以 `go-omni/project/run` 保留名称登记完成屏障。收到信号、Run 返回或报告故障后开始收尾,同时等待 Run 及其他已登记工作。Run 不应等待资源关闭:资源关闭发生在 Run 完成之后。
|
||||
|
||||
初始化失败同样收尾已创建的资源和已启动的工作。Init/Run 内 panic 被转为失败,安全收尾后进程以非零状态退出;任意自建 goroutine 的 panic 仍由项目负责处理。Run 不响应取消或其他工作无法确认结束时,`lifecycle.stopTimeout/drainTimeout/cancelTimeout` 限定等待窗口,返回 `ErrResourcesRetained`,跳过共享资源和日志关闭,以失败状态退出。取消后的工作不能被 Go 强制终止,保留资源不代表工作已经完成。SIGKILL 和进程崩溃不会执行退出钩子。
|
||||
|
||||
详见 [生命周期契约](lifecycle/README.md)。现成组件必须按其真实停止/完成语义适配,不能把某个 `Run/Close` 返回直接视为业务结束。
|
||||
|
||||
## 生成检查与兼容
|
||||
|
||||
```sh
|
||||
go-omni project generate --dry-run
|
||||
go-omni project generate
|
||||
go-omni project check # 等价于 project generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
生成器不执行项目 Go 代码、不下载依赖。check/预览只读,不创建锁文件;生成时使用独立项目锁、路径和哈希预检。生成文件被手工修改会报冲突,不提供强制覆盖。删除的生成文件可补回;缺失的项目所有文件必须显式恢复,避免把删除的业务文件当成空白骨架重建。
|
||||
|
||||
每个文件通过原子替换写入,普通写入失败回滚此前变更;整个目录不具备崩溃事务性。生成期间不要同时手工编辑。发现遗留 project.lock 时,先确认没有生成进程再手工移除。
|
||||
|
||||
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
|
||||
|
||||
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。现有 `workspace` 命令仍面向 API 契约工程;多个通用工程可分别执行 `project check --dir <目录>`,本版不引入混合工作区配置。
|
||||
12
PROJECT.md
12
PROJECT.md
@@ -1,6 +1,8 @@
|
||||
# 生成项目与文件维护边界
|
||||
# API 生成项目与文件维护边界
|
||||
|
||||
v0.25.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。
|
||||
|
||||
v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
|
||||
## 创建和日常开发
|
||||
|
||||
@@ -98,7 +100,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
|
||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||
|
||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后取消请求并关闭连接,再按 `http.shutdownCancelTimeout` 等待处理函数收尾。仍未结束时保留依赖并返回 `ErrResourcesRetained`,不会提前关闭数据库。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||
|
||||
## 依赖主动申请退出
|
||||
|
||||
@@ -109,7 +111,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
c.RequestShutdown(err)
|
||||
```
|
||||
|
||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再等待 HTTP 取消收尾窗口。通过 `svc.Finish(runErr)` 执行后台停机;确认工作结束才关闭依赖与日志,无法确认时保留资源并非零退出。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||
|
||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||
|
||||
@@ -215,7 +217,7 @@ get /download (DownloadReq)
|
||||
|
||||
## 已有服务接入步骤
|
||||
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.25.0-preview:
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.28.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 和提前返回的中间件一起验收。
|
||||
|
||||
43
README.md
43
README.md
@@ -1,8 +1,14 @@
|
||||
# GoOmni
|
||||
|
||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
|
||||
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.25.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
**v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。
|
||||
|
||||
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
|
||||
|
||||
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
|
||||
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.28.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
|
||||
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
|
||||
|
||||
@@ -34,6 +40,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
| --- | --- | --- |
|
||||
| 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) |
|
||||
| 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) |
|
||||
| 通用 Go 工程 | `go-omni project new/generate/check`;配置、日志、依赖、运行与退出 | [通用工程指南](PROGRAM.md)、[完整可运行示例](examples/program/README.md) |
|
||||
| 中间件依赖工厂、可选 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) |
|
||||
@@ -43,6 +50,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
| 数组、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) |
|
||||
| 通用国际化 | `i18n.New/Render`;可选 `WithLanguagePolicy/WithErrorLocalizer`;`go-omni i18n check/export/types/extract` | [资源格式与边界](i18n/README.md)、[CLI/HTTP/前端示例](examples/i18n/README.md)、[生成契约](examples/contracts/i18n.api) |
|
||||
| OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) |
|
||||
| 结构化业务日志 | `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#配置与日志热更新) |
|
||||
@@ -61,6 +69,22 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
| 可选结构转换与深拷贝 | `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) |
|
||||
|
||||
## 通用工程快速开始
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||
go-omni project new demo --module example.com/demo
|
||||
cd demo
|
||||
go mod tidy
|
||||
go run . -check-config
|
||||
go run .
|
||||
# Ctrl+C / SIGTERM 退出;项目自己的 Run 也可以完成后直接返回。
|
||||
go-omni project check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
项目只修改配置、svc 依赖初始化和 Run;默认没有 HTTP 端口、数据库、队列或自动重试。`main.go`、加载和装配代码由生成器维护;手改受管文件会报冲突,手写文件保持。完整维护边界、异常退出和超时行为见 [PROGRAM.md](PROGRAM.md)。
|
||||
|
||||
## 浏览器跨域 CORS
|
||||
|
||||
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
|
||||
@@ -153,7 +177,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.25.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
go-omni --help
|
||||
```
|
||||
@@ -310,7 +334,7 @@ go-omni new --api catalog.api \
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.28.0-preview
|
||||
```
|
||||
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||
@@ -586,6 +610,9 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
|
||||
| `server.http.readHeaderTimeout/readTimeout` | 请求头 / 请求读取超时 |
|
||||
| `server.http.writeTimeout/idleTimeout` | 响应写入 / Keep-Alive 空闲超时 |
|
||||
| `server.http.maxHeaderBytes` | 请求头大小限制,字节数 |
|
||||
| `server.http.defaultMaxBodyBytes` | 普通 App 路由默认请求体上限,零默认 8 MiB;路由可覆盖 |
|
||||
| `server.http.maxBodyBytes` | 服务请求体硬上限,路由不得超过;零不添加低于框架 1 TiB 的硬上限 |
|
||||
| `server.http.shutdownCancelTimeout` | 请求取消后等待处理函数收尾,零默认 5s;未结束则保留资源 |
|
||||
| `server.shutdownTimeout` | 优雅退出等待时间,默认 10s |
|
||||
| `server.enableLogger` / `server.log.level` | 访问日志开关、日志级别 |
|
||||
| `server.trustedProxies` | 可信代理 IP/CIDR,文件数组 |
|
||||
@@ -613,13 +640,15 @@ server:
|
||||
maxHeaderBytes: 1048576
|
||||
```
|
||||
|
||||
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**正值覆盖、零值保留所选网络层的原有默认行为、负值报错**;不配置时升级行为不变。超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
|
||||
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**网络 I/O 参数正值覆盖、零值保留所选网络层的原有默认行为、负值报错**。请求体与取消收尾字段按上表解释;网络超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
|
||||
|
||||
外部宿主在注册完成后可用 `app.ValidateHTTP()` 预检配置与路由限额。上传路由使用 `app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, handler)` 或 `.api @request(maxBytes: 16777216)`;限额涵盖整个请求体。配置硬上限时至少允许该值;multipart 单文件、字段数以及幂等中间件缓存限制仍独立配置。新增普通路由默认 8 MiB;原 nethttp 手写大请求请显式设置默认值或路由上限,生成路由既有 `maxBytes`(默认 1 MiB)保持。
|
||||
|
||||
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
||||
|
||||
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
|
||||
|
||||
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
|
||||
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。先停止接收新请求并等待排空,超时取消请求、关闭连接,再等待 `http.shutdownCancelTimeout` 收尾窗口。只有处理函数和它的 defer 全部退出才清理资源;仍有请求时返回 `lifecycle.ErrResourcesRetained`,跳过 App 清理钩子。生成入口通过 `deps.Finish(runErr)` 同步保留数据库等依赖及日志,非零退出。`App.Close`、`Engine`、`NewServer`、`RunWith` 共用这个完成屏障;业务自行启动的 goroutine、Hijack 后的工作需另行注册后台生命周期。
|
||||
|
||||
## 日常开发与升级
|
||||
|
||||
@@ -636,7 +665,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.25.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
CLI 与业务 module 固定使用 `v0.28.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
68
RELEASE.md
68
RELEASE.md
@@ -1,3 +1,69 @@
|
||||
# v0.28.0-preview
|
||||
|
||||
新增通用 Go 工程生成命令 `project new/generate/check`。默认只有配置、日志、svc 依赖装配、生命周期与项目维护的 Run 入口,不生成 API、HTTP Handler、Swagger、业务任务或网络监听,也不自动连接数据库/队列或重试任务。常驻和一次性程序由普通 Go 代码决定。
|
||||
|
||||
入口监督 Run 的真实完成并复用现有 Runtime:信号、正常结束、运行错误或初始化失败都会进入收尾;先停止接收工作、等待已登记工作及 Run 结束,再反序释放依赖并关闭日志。Run/Init panic 转为失败;超时仍有工作则传播 ErrResourcesRetained,保留共享资源及日志,以非零状态退出。组件登记在 svc.Init 完成,业务语义和调度方式由项目负责。
|
||||
|
||||
配置遵守现有显式文件引用约定;支持 -check-config,在初始化日志和依赖前校验。生成器只管理 main/loadGen/runtimeGen,手写文件保留,生成文件被修改时拒绝覆盖;check/预览只读,独立 manifest/锁和共享文件事务保障生成。新模板要求框架 v0.28.0-preview;已有 new/api/workspace 行为保留,无强制迁移和新增第三方依赖。
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||
go-omni project new demo --module example.com/demo
|
||||
cd demo
|
||||
go mod tidy
|
||||
go run . -check-config
|
||||
go run .
|
||||
go-omni project check
|
||||
```
|
||||
|
||||
完整 [通用工程指南](PROGRAM.md)、[可运行示例](examples/program/README.md)、[回归记录](reports/programAcceptance.md)。56 个测试包全量 race 与 Go 1.22 回归、生成消费者、vet/build 和真实进程退出验证通过。
|
||||
|
||||
---
|
||||
|
||||
# v0.27.0-preview
|
||||
|
||||
新增统一请求体策略与请求完成屏障,无业务字段、数据库依赖或新第三方包。`server.http.defaultMaxBodyBytes` 控制普通路由默认值(零为 8 MiB),`maxBodyBytes` 为不可由路由突破的服务硬上限(零仅保留 1 TiB 框架上界)。已有显式路由和生成契约限额保持,冲突会在启动前报错;旧 nethttp 手写大请求需显式设置路由或默认上限。multipart 单文件、中间件缓存及代理限额仍各自配置。
|
||||
|
||||
HTTP 关闭连接后继续等待处理器实际退出,收尾窗口由 `server.http.shutdownCancelTimeout` 设置(零默认 5s)。超时仍有处理器运行时返回 `lifecycle.ErrResourcesRetained`,App/Runtime/生成入口保留共享依赖和日志;不再将连接关闭当成业务完成。`Runtime.Finish(runErr)` 会传播该信号,手写入口必须处理它,避免无条件关闭额外资源。该行为适用于全部 App 服务入口;HTTP/2 低层 Shutdown 同时等待独立处理器。
|
||||
|
||||
详见 [请求体策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)、[运行示例](examples/httpSafety/README.md)。CLI 与框架固定使用同版 v0.27.0-preview;模板最低兼容版本仍为 v0.22,HTTP 安全改进需要升级运行时。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.27.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
54 个测试包全量 race、Go 1.22 兼容检查、生成项目集成测试、vet/build 及真实进程关闭检查通过,见 [验收记录](reports/httpSafetyAcceptance.md)。
|
||||
|
||||
空模块缓存安装远程固定版本后,10 组生成消费者和 98 项发布检查通过,耗时 107.57 秒,无本地 replace;覆盖上传限额、取消收尾、HTTP/2 断连、生成项目依赖与日志保留。标签固定在 `3b00bb55d189cbe480c7d15c8d14bcd09d3f2d52`,见 [远程验收记录](reports/httpSafetyRemote.json)。
|
||||
|
||||
---
|
||||
|
||||
# v0.26.0-preview
|
||||
|
||||
新增可独立使用的 i18n 底层模块及可选 HTTP/错误适配。项目维护文案和语言设置,框架提供不可变词典、语言匹配、回退、参数与分支、检查和导出;没有绑定业务身份、页面框架、数据库或响应字段名。
|
||||
|
||||
前后端共享版本化消息资源和固定 Unicode CLDR 46.1 基数复数规则。CLI 只读输入,支持源目录提取、缺译/参数/动态引用检查、按命名空间导出语言包和 TypeScript 类型。可选浏览器模块先加载并验证再切换,失败保留当前语言,旧请求不能覆盖新选择。
|
||||
|
||||
错误目录新增可选 `translation` 引用。内置响应使用展示译文,原目录文案继续参与契约校验;自定义 Encoder 通过 `PublicMessage()` 显式采用译文。业务码、HTTP 状态、成功/失败语义、cause、直接协议输出和幂等首次快照保持。旧配置与生成项目不启用时无需迁移。
|
||||
|
||||
完整 [使用指南](i18n/README.md)、[通用示例](examples/i18n/README.md)、[验收记录](reports/i18nAcceptance.md)。包含 translation 的新契约远程依赖最低版本为 v0.26.0-preview;未使用该元数据的模板最低版本仍为 v0.22。CLI 与框架使用同版。
|
||||
|
||||
同时修复 JSON 结束定界符读取失败时丢失原始错误的问题,使请求体超过外层限制时在 Go 1.22 中也保持 HTTP 413;共享目录测试工程使用 Go 1.22。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
发布前 54 个测试包全量 race 与 Go 1.22(含生成消费者)、vet/build、真实进程检查通过。空缓存安装远程固定版本后,10 组生成消费者和 87 项发布检查通过,耗时 83.76 秒,无本地 replace;包含下载模块的国际化、CLDR/浏览器一致性、TypeScript 类型和 CLI 工具链验证。标签固定在 `d1a747c36f15d964dd445d661623b68a45892adf`,[远程验收记录](reports/i18nRemote.json)。
|
||||
|
||||
---
|
||||
|
||||
# v0.25.0-preview
|
||||
|
||||
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
|
||||
@@ -18,6 +84,8 @@ go test ./...
|
||||
|
||||
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
|
||||
|
||||
49 个测试包全量 race、Go 1.22、vet/build、取消竞争复测通过;成功事务没有新增分配。固定远程版本在空缓存下通过 77 项发布检查和 9 组生成消费者,耗时 71.93 秒。独立 MySQL TLS 容器的 53 条测试记录(含子用例)通过,包含真实提交/回滚后的确认丢失、可选 GORM、生成项目和后台关闭,耗时 28.49 秒。均无本地 replace,标签固定在 `7a177744ab2bb85f23efc0ec267fd905d349bd4b`。见 [远程发布记录](reports/transactionRemote.json)、[远程 MySQL 记录](reports/transactionMySQLRemote.json)。
|
||||
|
||||
---
|
||||
|
||||
# v0.24.0-preview(历史版本)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 公共响应体与独立接口协议
|
||||
|
||||
v0.26 新增的 [国际化适配](i18n/README.md#接入-go-omni-错误与-http)保留现有响应契约。`Result.Message` 是校验使用的目录原文;`MessageOverride` 是可选展示译文,`PublicMessage()` 返回最终文案。内置 Formatter 自动使用译文;自定义 Encoder 显式选择,固定文本/XML 回执和直接输出不自动改写。
|
||||
|
||||
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
|
||||
|
||||
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
|
||||
|
||||
69
app/app.go
69
app/app.go
@@ -33,6 +33,10 @@ type App struct {
|
||||
closeErr error
|
||||
clientIPResolver *httpx.ClientIPResolver
|
||||
responseEncoder response.Encoder
|
||||
requests requestDrain
|
||||
bodyLimits []routeBodyLimit
|
||||
validateOnce sync.Once
|
||||
validateErr error
|
||||
}
|
||||
|
||||
// Runner is the small lifecycle boundary used by an HTTP server
|
||||
@@ -100,7 +104,14 @@ 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)
|
||||
application.requests.done = make(chan struct{})
|
||||
application.handler = corsPolicy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength > application.hardBodyLimit() {
|
||||
application.adapt(func(*httpx.Context) error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
router.ServeHTTP(w, r)
|
||||
}))
|
||||
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) {
|
||||
@@ -113,15 +124,31 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
||||
}
|
||||
|
||||
// Router exposes the underlying routing contract for advanced registration.
|
||||
// Serve requests through App or Engine so application CORS policy is applied.
|
||||
// Serve through App or Engine for CORS, hard body limits and shutdown tracking.
|
||||
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.handler }
|
||||
func (a *App) Engine() http.Handler { return a }
|
||||
|
||||
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
||||
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
ctx, work := a.requests.begin(request.Context())
|
||||
if work == nil {
|
||||
// Do not invoke encoders, middleware or loggers after their owners close.
|
||||
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer a.requests.end(work)
|
||||
a.validateOnce.Do(func() { a.validateErr = a.ValidateHTTP() })
|
||||
if a.validateErr != nil {
|
||||
http.Error(writer, "invalid HTTP configuration", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
request = request.WithContext(ctx)
|
||||
if request.Body != nil {
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, a.hardBodyLimit())
|
||||
}
|
||||
a.handler.ServeHTTP(writer, request)
|
||||
}
|
||||
|
||||
@@ -143,7 +170,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App {
|
||||
}
|
||||
|
||||
// OnShutdown registers a cleanup function owned by the application. Cleanup
|
||||
// functions run in reverse registration order when Close or RunContext ends.
|
||||
// functions run in reverse registration order after all handlers have returned.
|
||||
// Incomplete draining skips these callbacks and returns ErrResourcesRetained.
|
||||
// This keeps omni.New as the only public startup entry point while allowing
|
||||
// projects to attach databases, loggers and other resources to its lifecycle.
|
||||
func (a *App) OnShutdown(cleanup func() error) *App {
|
||||
@@ -153,9 +181,21 @@ func (a *App) OnShutdown(cleanup func() error) *App {
|
||||
return a
|
||||
}
|
||||
|
||||
// Close releases application resources. It is safe to call more than once.
|
||||
// Close stops admission, cancels active requests and waits for their handlers
|
||||
// to return before releasing resources. It is idempotent. If cancellation grace
|
||||
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
|
||||
// Use RunContext/ServeContext for graceful draining before cancellation. Do not
|
||||
// call Close from a handler or cleanup callback; it would wait for itself.
|
||||
func (a *App) Close() error {
|
||||
a.closeOnce.Do(func() {
|
||||
timeout := a.config.HTTP.ShutdownCancelTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if err := a.requests.cancelAndWait(timeout); err != nil {
|
||||
a.closeErr = err
|
||||
return
|
||||
}
|
||||
for index := len(a.closers) - 1; index >= 0; index-- {
|
||||
if err := a.closers[index](); err != nil {
|
||||
a.closeErr = errors.Join(a.closeErr, err)
|
||||
@@ -177,7 +217,7 @@ func (a *App) Run(address ...string) error {
|
||||
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
|
||||
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
||||
func (a *App) NewServer(address string) *http.Server {
|
||||
if err := a.config.HTTP.Validate(); err != nil {
|
||||
if err := a.ValidateHTTP(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if address == "" {
|
||||
@@ -187,7 +227,7 @@ func (a *App) NewServer(address string) *http.Server {
|
||||
}
|
||||
}
|
||||
cfg := a.config.HTTP
|
||||
return &http.Server{Addr: address, Handler: a.handler,
|
||||
return &http.Server{Addr: address, Handler: a,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
||||
@@ -202,6 +242,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) (runErr error)
|
||||
if ctx.Err() != nil {
|
||||
return a.Close()
|
||||
}
|
||||
stopWatching := a.watchShutdown(ctx)
|
||||
defer stopWatching()
|
||||
serve, err := a.prepareTransport()
|
||||
if err != nil {
|
||||
return errors.Join(err, a.Close())
|
||||
@@ -228,6 +270,11 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (ru
|
||||
if ctx.Err() != nil {
|
||||
return a.Close()
|
||||
}
|
||||
if err := a.ValidateHTTP(); err != nil {
|
||||
return errors.Join(err, a.Close())
|
||||
}
|
||||
stopWatching := a.watchShutdown(ctx)
|
||||
defer stopWatching()
|
||||
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
||||
}
|
||||
|
||||
@@ -261,12 +308,16 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
// Serve returning only proves the accept loop ended. Cancel any remaining
|
||||
// connections before the application completion barrier and cleanup.
|
||||
_ = server.Close()
|
||||
closeErr := a.Close()
|
||||
if err == http.ErrServerClosed {
|
||||
return closeErr
|
||||
}
|
||||
return errors.Join(err, closeErr)
|
||||
case <-ctx.Done():
|
||||
a.requests.stop()
|
||||
shutdownTimeout := a.config.ShutdownTimeout
|
||||
if shutdownTimeout <= 0 {
|
||||
shutdownTimeout = 10 * time.Second
|
||||
@@ -275,8 +326,8 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
||||
defer cancel()
|
||||
shutdownErr := server.Shutdown(shutdownCtx)
|
||||
if shutdownErr != nil {
|
||||
// Shutdown leaves active connections open on timeout. Close them so
|
||||
// request contexts are canceled before releasing shared resources.
|
||||
// Closing sockets requests cancellation; App.Close separately waits
|
||||
// for the actual handler return before releasing shared resources.
|
||||
shutdownErr = errors.Join(shutdownErr, server.Close())
|
||||
}
|
||||
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
||||
|
||||
63
app/body.go
63
app/body.go
@@ -1,11 +1,68 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
type routeBodyLimit struct {
|
||||
method, path string
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
func (a *App) hardBodyLimit() int64 {
|
||||
if a.config.HTTP.MaxBodyBytes > 0 {
|
||||
return a.config.HTTP.MaxBodyBytes
|
||||
}
|
||||
return httplimits.MaxBodyBytes
|
||||
}
|
||||
|
||||
func (a *App) defaultBodyLimit() int64 {
|
||||
limit := a.config.HTTP.DefaultMaxBodyBytes
|
||||
if limit <= 0 {
|
||||
limit = httplimits.DefaultBodyBytes
|
||||
}
|
||||
if limit > a.hardBodyLimit() {
|
||||
limit = a.hardBodyLimit()
|
||||
}
|
||||
// Invalid configuration is rejected at startup or the first external request.
|
||||
if limit > httplimits.MaxBodyBytes {
|
||||
limit = httplimits.MaxBodyBytes
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// ValidateHTTP checks HTTP settings and registered route body limits without
|
||||
// opening a listener. Call after registration when hosting App/Engine externally.
|
||||
// Built-in serving entry points and RunWith perform this check automatically.
|
||||
func (a *App) ValidateHTTP() error {
|
||||
if err := a.config.HTTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, route := range a.bodyLimits {
|
||||
if route.maxBytes > a.hardBodyLimit() {
|
||||
return &config.ConfigError{Field: "http.maxBodyBytes", Message: fmt.Sprintf("route %s %s maxBytes=%d exceeds service hard limit=%d; raise http.maxBodyBytes or lower the route limit", route.method, route.path, route.maxBytes, a.hardBodyLimit())}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinRoutePath(prefix, path string) string {
|
||||
if path == "" {
|
||||
if prefix == "" {
|
||||
return "/"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
}
|
||||
|
||||
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||
checked := httpx.LimitBody(maxBytes)(handler)
|
||||
if encoder == nil {
|
||||
@@ -25,8 +82,10 @@ func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler http
|
||||
// the response policy, including auth failures and parameter errors.
|
||||
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
|
||||
a.bodyLimits = append(a.bodyLimits, routeBodyLimit{method, path, maxBytes})
|
||||
}
|
||||
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
|
||||
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
|
||||
g.app.bodyLimits = append(g.app.bodyLimits, routeBodyLimit{method, joinRoutePath(g.prefix, path), maxBytes})
|
||||
}
|
||||
|
||||
224
app/bodyPolicy_test.go
Normal file
224
app/bodyPolicy_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 serveBodyPolicy(t *testing.T, a *App, transport string) (string, *http.Client) {
|
||||
t.Helper()
|
||||
cert, key, clientTLS := testutil.CertificateFiles(t)
|
||||
a.config.Transport = transport
|
||||
a.config.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
|
||||
l, 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, l) }()
|
||||
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS, ForceAttemptHTTP2: transport == config.TransportHTTP2}}
|
||||
t.Cleanup(func() {
|
||||
client.CloseIdleConnections()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop")
|
||||
}
|
||||
})
|
||||
return "https://" + l.Addr().String(), client
|
||||
}
|
||||
|
||||
func consumeBody(c *httpx.Context) error {
|
||||
_, err := io.Copy(io.Discard, c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Text(200, "accepted")
|
||||
}
|
||||
|
||||
func TestUploadPolicyAcrossTransports(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
a := New()
|
||||
a.POST("/default", consumeBody)
|
||||
a.HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||
base, client := serveBodyPolicy(t, a, transport)
|
||||
payload := bytes.Repeat([]byte{'x'}, 9<<20)
|
||||
for _, chunked := range []bool{false, true} {
|
||||
for _, path := range []string{"/default", "/large", "/v1/files/large"} {
|
||||
var body io.Reader = bytes.NewReader(payload)
|
||||
if chunked {
|
||||
body = struct{ io.Reader }{body}
|
||||
}
|
||||
req, _ := http.NewRequest("POST", base+path, body)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 200
|
||||
if path == "/default" {
|
||||
want = 413
|
||||
}
|
||||
if res.StatusCode != want || (want == 413 && !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||
t.Fatalf("%s unknownLength=%v: %d %s", path, chunked, res.StatusCode, data)
|
||||
}
|
||||
if transport == config.TransportHTTP2 && res.ProtoMajor != 2 {
|
||||
t.Fatal("HTTP/2 not negotiated")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredBodyPolicyBeforeMiddleware(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 32, MaxBodyBytes: 128}))
|
||||
var calls atomic.Int32
|
||||
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
data, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Request().Body = io.NopCloser(bytes.NewReader(data))
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
h := func(c *httpx.Context) error { calls.Add(1); return consumeBody(c) }
|
||||
a.POST("/default", h)
|
||||
a.HandleWithBodyLimit("POST", "/override", 64, nil, h)
|
||||
a.HandleWithBodyLimit("POST", "/hard", 128, nil, h)
|
||||
base, client := serveBodyPolicy(t, a, transport)
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
size, status int
|
||||
}{
|
||||
{"/default", 32, 200}, {"/default", 33, 413}, {"/override", 64, 200}, {"/override", 65, 413}, {"/hard", 128, 200}, {"/hard", 129, 413},
|
||||
} {
|
||||
for _, unknown := range []bool{false, true} {
|
||||
before := calls.Load()
|
||||
var body io.Reader = strings.NewReader(strings.Repeat("a", tc.size))
|
||||
if unknown {
|
||||
body = struct{ io.Reader }{body}
|
||||
}
|
||||
res, err := client.Post(base+tc.path, "application/octet-stream", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil || res.StatusCode != tc.status {
|
||||
t.Fatalf("%+v unknown=%v: %d %s %v", tc, unknown, res.StatusCode, data, err)
|
||||
}
|
||||
if tc.status == 413 && (calls.Load() != before || !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||
t.Fatal("oversize reached business or lost error", string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyHardLimitConflictBeforeServing(t *testing.T) {
|
||||
for _, entry := range []string{"RunContext", "ServeContext", "RunWith", "NewServer", "Engine"} {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{MaxBodyBytes: 8 << 20}))
|
||||
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/upload/", 16<<20, nil, consumeBody)
|
||||
closed := 0
|
||||
a.OnShutdown(func() error { closed++; return nil })
|
||||
if err := a.ValidateHTTP(); err == nil {
|
||||
t.Fatal("external preflight accepted conflicting route limit")
|
||||
}
|
||||
var err error
|
||||
switch entry {
|
||||
case "RunContext":
|
||||
err = a.RunContext(context.Background(), "bad address")
|
||||
case "ServeContext":
|
||||
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
err = a.ServeContext(context.Background(), l)
|
||||
if _, e = l.Accept(); !errors.Is(e, net.ErrClosed) {
|
||||
t.Fatal("listener leaked", e)
|
||||
}
|
||||
case "RunWith":
|
||||
err = a.RunWith(context.Background(), runnerFunc(func(context.Context, http.Handler, string) error { t.Error("runner started"); return nil }))
|
||||
case "NewServer":
|
||||
func() {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
err, _ = p.(error)
|
||||
}
|
||||
}()
|
||||
a.NewServer("")
|
||||
}()
|
||||
_ = a.Close()
|
||||
case "Engine":
|
||||
w := httptest.NewRecorder()
|
||||
a.Engine().ServeHTTP(w, httptest.NewRequest("POST", "/v1/files/upload/", nil))
|
||||
if w.Code != 503 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
err = a.validateErr
|
||||
_ = a.Close()
|
||||
}
|
||||
for _, part := range []string{"POST /v1/files/upload/", "16777216", "8388608", "http.maxBodyBytes"} {
|
||||
if err == nil || !strings.Contains(err.Error(), part) {
|
||||
t.Fatalf("missing %q: %v", part, err)
|
||||
}
|
||||
}
|
||||
if closed != 1 {
|
||||
t.Fatal("cleanup count", closed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBodyLimitRegistrationPaths(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 4, MaxBodyBytes: 16}))
|
||||
h := func(*httpx.Context) error { t.Error("oversize reached business"); return nil }
|
||||
encoder, _ := response.New(response.Config{})
|
||||
a.Handle("POST", "/handle", h)
|
||||
a.HandleWithResponse("POST", "/response", encoder, h)
|
||||
a.Any("/any", h)
|
||||
g := a.Group("/group")
|
||||
g.POST("/post", h)
|
||||
g.Any("/any", h)
|
||||
g.HandleWithResponse("POST", "/response", encoder, h)
|
||||
for _, path := range []string{"/handle", "/response", "/any", "/group/post", "/group/any", "/group/response"} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader("12345"))
|
||||
a.ServeHTTP(w, r)
|
||||
if w.Code != 413 {
|
||||
t.Fatal(path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
241
app/i18n_test.go
Normal file
241
app/i18n_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func localizedCatalog(namespace string, status int) apierrors.Catalog {
|
||||
return errori18n.Bind(apierrors.Catalog{DefaultStatus: status, Definitions: map[string]apierrors.Definition{
|
||||
"SAME": {Code: int64(9223372036854775807), Message: namespace + ":{value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}},
|
||||
"INTERNAL_ERROR": {Status: 500, Code: int64(500), Message: "internal"},
|
||||
"FIXED": {Status: 401, Code: int64(401), Message: "fixed {literal}"},
|
||||
}}, namespace)
|
||||
}
|
||||
func localizedOptions(t *testing.T, catalogs ...apierrors.Catalog) (*httpi18n.Policy, *errori18n.Localizer) {
|
||||
t.Helper()
|
||||
docs := []i18n.Document{}
|
||||
for _, catalog := range catalogs {
|
||||
source, err := errori18n.Source(catalog, "en")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs = append(docs, source...)
|
||||
for _, d := range source {
|
||||
docs = append(docs, i18n.Document{Version: 1, Namespace: d.Namespace, Locale: "zh-Hans", Messages: map[string]i18n.Message{
|
||||
"SAME": {Value: i18n.Pattern{Text: d.Namespace + "译文:{value}"}}, "FIXED": {Value: i18n.Pattern{Text: "需要认证"}}, "INTERNAL_ERROR": {Value: i18n.Pattern{Text: "系统错误"}},
|
||||
}})
|
||||
}
|
||||
}
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, docs...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := errori18n.New(b, nil)
|
||||
for _, c := range catalogs {
|
||||
if err := l.Validate(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, l
|
||||
}
|
||||
|
||||
func TestI18nRouteScopesEarlyFailuresAndReplay(t *testing.T) {
|
||||
global, local := localizedCatalog("app", 200), localizedCatalog("route", 409)
|
||||
policy, localizer := localizedOptions(t, global, local)
|
||||
format := response.Config{CodeType: "int64", SuccessCode: int64(0), ErrorCode: int64(999), Body: map[string]any{"error": map[string]any{"reason": "${message}", "id": "${code}"}, "ok": "${ok}", "data": "${data}"}, DataMode: "always", Messages: map[string]string{"FIXED": "fixed {literal}"}}
|
||||
app := omni.New(omni.WithErrors(global), omni.WithResponse(format), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
|
||||
defer app.Close()
|
||||
private := errors.New("private-value")
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
switch c.Header("X-Early") {
|
||||
case "auth":
|
||||
return apierrors.ByCode("FIXED")
|
||||
case "panic":
|
||||
panic(private)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
handler := func(c *httpx.Context) error {
|
||||
if c.Header("X-Params") == "missing" {
|
||||
return apierrors.ByCode("SAME")
|
||||
}
|
||||
return apierrors.ByCode("SAME").WithParams(map[string]any{"value": "{literal}"}).WithCause(private)
|
||||
}
|
||||
app.GET("/global", handler)
|
||||
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
|
||||
})
|
||||
app.HandleWithResponse("GET", "/route", response.RawJSON(response.MustWithErrors(local, response.MustNew(format))), handler, bridge)
|
||||
for _, path := range []string{"/global", "/route"} {
|
||||
for _, locale := range []string{"en", "zh-CN"} {
|
||||
for _, mode := range []string{"", "auth", "panic", "missing"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Accept-Language", locale)
|
||||
r.Header.Set("X-Early", mode)
|
||||
r.Header.Set("X-Params", mode)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
var body struct {
|
||||
Error struct {
|
||||
Reason string `json:"reason"`
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"error"`
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err, w.Body.String())
|
||||
}
|
||||
ns, status := "app", 200
|
||||
if path == "/route" {
|
||||
ns, status = "route", 409
|
||||
}
|
||||
want := ns + ":{literal}"
|
||||
if locale != "en" {
|
||||
want = ns + "译文:{literal}"
|
||||
}
|
||||
if mode == "auth" {
|
||||
status = 401
|
||||
want = "fixed {literal}"
|
||||
if locale != "en" {
|
||||
want = "需要认证"
|
||||
}
|
||||
} else if mode == "panic" || mode == "missing" {
|
||||
status = 500
|
||||
want = "internal"
|
||||
if locale != "en" {
|
||||
want = "系统错误"
|
||||
}
|
||||
}
|
||||
if w.Code != status || body.OK || body.Error.Reason != want || strings.Contains(w.Body.String(), "private-value") {
|
||||
t.Fatal(path, locale, mode, w.Code, w.Body.String())
|
||||
}
|
||||
if mode == "" && body.Error.ID.String() != "9223372036854775807" {
|
||||
t.Fatal("public code changed", body.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, shared := range []bool{false, true} {
|
||||
cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "test", nil }}
|
||||
if shared {
|
||||
cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
|
||||
}
|
||||
idem, err := middleware.NewIdempotency(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/localReplay"
|
||||
if shared {
|
||||
path = "/sharedReplay"
|
||||
}
|
||||
calls := 0
|
||||
app.POST(path, func(c *httpx.Context) error { calls++; return handler(c) }, idem.Middleware())
|
||||
var first string
|
||||
for _, locale := range []string{"en", "zh-CN"} {
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(`{}`))
|
||||
r.Header.Set("Idempotency-Key", "same")
|
||||
r.Header.Set("Accept-Language", locale)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if locale == "en" {
|
||||
first = w.Body.String()
|
||||
} else if w.Body.String() != first || w.Header().Get("Idempotency-Replayed") != "true" {
|
||||
t.Fatal("replay changed", w.Body.String(), first)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatal("language retriggered business", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestI18nExplicitProtocolsAndConcurrentApps(t *testing.T) {
|
||||
apps := []*omni.App{}
|
||||
for _, ns := range []string{"one", "two"} {
|
||||
c := localizedCatalog(ns, 200)
|
||||
p, l := localizedOptions(t, c)
|
||||
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(l))
|
||||
defer a.Close()
|
||||
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||
a.HandleWithResponse("GET", "/ack", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
|
||||
if !r.IsError() || r.Message != "fixed {literal}" || r.PublicMessage() != "需要认证" {
|
||||
t.Error(r)
|
||||
}
|
||||
return response.Text(200, "ACK"), nil
|
||||
}), func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||
a.GET("/direct", func(c *httpx.Context) error { return c.Text(200, "fixed-protocol") })
|
||||
apps = append(apps, a)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 60; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
locale, want := "en", "fixed {literal}"
|
||||
if n%2 == 0 {
|
||||
locale, want = "zh-CN", "需要认证"
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Language", locale)
|
||||
w := httptest.NewRecorder()
|
||||
apps[n%len(apps)].ServeHTTP(w, r)
|
||||
if w.Code != 401 || !strings.Contains(w.Body.String(), want) {
|
||||
t.Error(w.Code, w.Body.String())
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, path := range []string{"/ack", "/direct"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Accept-Language", "zh-CN")
|
||||
w := httptest.NewRecorder()
|
||||
apps[0].ServeHTTP(w, r)
|
||||
want := "ACK"
|
||||
if path == "/direct" {
|
||||
want = "fixed-protocol"
|
||||
}
|
||||
if w.Body.String() != want {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type panickingLocalizer struct{}
|
||||
|
||||
func (panickingLocalizer) Localize(context.Context, *apierrors.Registry, *apierrors.APIError) (string, bool) {
|
||||
panic("presentation failed")
|
||||
}
|
||||
func TestI18nAdapterFailurePreservesBusiness(t *testing.T) {
|
||||
c := localizedCatalog("test", 200)
|
||||
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithErrorLocalizer(panickingLocalizer{}))
|
||||
defer a.Close()
|
||||
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != 401 || !strings.Contains(w.Body.String(), "fixed {literal}") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
88
app/requests.go
Normal file
88
app/requests.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
// Admission and completion share a lock: once stopped, no request can enter
|
||||
// between observing zero active handlers and releasing their dependencies.
|
||||
type requestDrain struct {
|
||||
mu sync.Mutex
|
||||
stopped bool
|
||||
active map[*requestWork]struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type requestWork struct{ cancel context.CancelFunc }
|
||||
|
||||
func (d *requestDrain) begin(ctx context.Context) (context.Context, *requestWork) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return ctx, nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
w := &requestWork{cancel: cancel}
|
||||
if d.active == nil {
|
||||
d.active = make(map[*requestWork]struct{})
|
||||
}
|
||||
d.active[w] = struct{}{}
|
||||
return ctx, w
|
||||
}
|
||||
|
||||
func (d *requestDrain) end(w *requestWork) {
|
||||
w.cancel()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
delete(d.active, w)
|
||||
if d.stopped && len(d.active) == 0 {
|
||||
close(d.done)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *requestDrain) stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
d.stopped = true
|
||||
if len(d.active) == 0 {
|
||||
close(d.done)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *requestDrain) cancelAndWait(timeout time.Duration) error {
|
||||
d.stop()
|
||||
d.mu.Lock()
|
||||
for w := range d.active {
|
||||
w.cancel()
|
||||
}
|
||||
d.mu.Unlock()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-d.done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
select {
|
||||
case <-d.done:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
return fmt.Errorf("go-omni: HTTP handlers did not finish within cancellation grace %s: %w", timeout,
|
||||
errors.Join(context.DeadlineExceeded, lifecycle.ErrResourcesRetained))
|
||||
}
|
||||
}
|
||||
|
||||
// watchShutdown stops admission on the early signal without canceling accepted
|
||||
// work. The transport retains its graceful drain budget before Close cancels.
|
||||
func (a *App) watchShutdown(ctx context.Context) func() bool {
|
||||
return context.AfterFunc(ctx, a.requests.stop)
|
||||
}
|
||||
167
app/requests_test.go
Normal file
167
app/requests_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/lifecycle"
|
||||
)
|
||||
|
||||
func TestShutdownWaitsForCancellationCleanup(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
a := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(30*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: time.Second}))
|
||||
started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
var closed atomic.Int32
|
||||
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||
a.GET("/work", func(c *httpx.Context) error {
|
||||
close(started)
|
||||
<-c.Context().Done()
|
||||
close(canceled)
|
||||
<-release
|
||||
if closed.Load() != 0 {
|
||||
t.Error("dependency closed during cancellation cleanup")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.ServeContext(ctx, l) }()
|
||||
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
requestDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestDone)
|
||||
res, err := client.Get("https://" + l.Addr().String() + "/work")
|
||||
if err == nil {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
}
|
||||
}()
|
||||
await(t, started)
|
||||
cancel()
|
||||
await(t, canceled)
|
||||
if closed.Load() != 0 {
|
||||
t.Fatal("dependency closed on cancellation")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatal("returned before handler cleanup", err)
|
||||
default:
|
||||
}
|
||||
// Even an in-memory invocation through Engine must now be rejected.
|
||||
w := httptest.NewRecorder()
|
||||
a.Engine().ServeHTTP(w, httptest.NewRequest("GET", "/work", nil))
|
||||
if w.Code != 503 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
await(t, requestDone)
|
||||
if closed.Load() != 1 {
|
||||
t.Fatal("cleanup count", closed.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalServingEntriesRetainActiveResources(t *testing.T) {
|
||||
for _, entry := range []string{"App", "Engine", "NewServer", "RunWith"} {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 30 * time.Millisecond}))
|
||||
started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var closed atomic.Int32
|
||||
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||
a.GET("/", func(*httpx.Context) error {
|
||||
close(started)
|
||||
<-release
|
||||
if closed.Load() != 0 {
|
||||
t.Error("closed while active")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
var h http.Handler = a
|
||||
if entry == "Engine" {
|
||||
h = a.Engine()
|
||||
}
|
||||
if entry == "NewServer" {
|
||||
h = a.NewServer("").Handler
|
||||
}
|
||||
invoke := func(h http.Handler) {
|
||||
defer close(returned)
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||
}
|
||||
defer func() { close(release); await(t, returned) }()
|
||||
var err error
|
||||
if entry == "RunWith" {
|
||||
err = a.RunWith(context.Background(), runnerFunc(func(_ context.Context, h http.Handler, _ string) error { go invoke(h); await(t, started); return nil }))
|
||||
} else {
|
||||
go invoke(h)
|
||||
await(t, started)
|
||||
err = a.Close()
|
||||
}
|
||||
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() != 0 {
|
||||
t.Fatal("retention failed", err, closed.Load())
|
||||
}
|
||||
if err = a.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal("repeat close lost retention", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAdmissionAndCloseRace(t *testing.T) {
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
a := New()
|
||||
var closed atomic.Bool
|
||||
a.OnShutdown(func() error { closed.Store(true); return nil })
|
||||
a.GET("/", func(c *httpx.Context) error {
|
||||
if closed.Load() {
|
||||
t.Error("handler admitted after resource release")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
a.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||
routes.Handle(method, path, a.adapt(handler, middleware...))
|
||||
routes.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||
}
|
||||
|
||||
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||
@@ -28,6 +28,9 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
|
||||
}
|
||||
}
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if a.config.LanguagePolicy != nil {
|
||||
request = a.config.LanguagePolicy.Apply(writer, request)
|
||||
}
|
||||
request = request.WithContext(logx.WithLogger(contract.WithRequestPolicy(request.Context(), a.config.Request), a.config.LogInstance))
|
||||
if len(a.config.TrustedProxies) > 0 {
|
||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
|
||||
@@ -40,6 +43,7 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
|
||||
requestContext.SetErrorRegistry(provider.ErrorRegistry())
|
||||
}
|
||||
requestContext.SetResponseEncoder(encoder)
|
||||
requestContext.SetErrorLocalizer(a.config.ErrorLocalizer)
|
||||
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
|
||||
if err := wrapped(requestContext); err != nil {
|
||||
a.writeError(requestContext, err)
|
||||
@@ -58,7 +62,7 @@ func (a *App) HandleWithResponse(method, path string, encoder response.Encoder,
|
||||
if encoder == nil {
|
||||
encoder = a.responseEncoder
|
||||
}
|
||||
a.router.Handle(method, path, a.adaptResponse(encoder, handler, middleware...))
|
||||
a.router.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), encoder, handler, middleware...))
|
||||
}
|
||||
|
||||
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
@@ -82,18 +86,19 @@ func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Mid
|
||||
}
|
||||
|
||||
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
a.router.Any(path, a.adapt(handler, middleware...))
|
||||
a.router.Any(path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||
}
|
||||
|
||||
// RouterGroup scopes a route prefix and optional middleware.
|
||||
type RouterGroup struct {
|
||||
prefix string
|
||||
group httpx.RouterGroup
|
||||
app *App
|
||||
middlewares []httpx.Middleware
|
||||
}
|
||||
|
||||
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware}
|
||||
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware, prefix: joinRoutePath("", path)}
|
||||
}
|
||||
|
||||
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
||||
@@ -104,7 +109,7 @@ func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
||||
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||
combined = append(combined, middleware...)
|
||||
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined}
|
||||
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined, prefix: joinRoutePath(g.prefix, path)}
|
||||
}
|
||||
|
||||
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||
@@ -124,7 +129,7 @@ func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.E
|
||||
}
|
||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||
combined = append(combined, middleware...)
|
||||
g.group.Handle(method, path, g.app.adaptResponse(encoder, handler, combined...))
|
||||
g.group.Handle(method, path, g.app.bodyHandler(g.app.defaultBodyLimit(), encoder, handler, combined...))
|
||||
}
|
||||
|
||||
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
@@ -150,5 +155,5 @@ func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...h
|
||||
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||
combined = append(combined, middleware...)
|
||||
g.group.Any(path, g.app.adapt(handler, combined...))
|
||||
g.group.Any(path, g.app.bodyHandler(g.app.defaultBodyLimit(), nil, handler, combined...))
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||
)
|
||||
|
||||
// ServeContext serves a caller-created listener using the configured transport
|
||||
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||
// and runs resource cleanup after the server has stopped.
|
||||
// and runs resource cleanup only after admitted handlers have returned.
|
||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if listener == nil {
|
||||
@@ -30,6 +31,8 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
||||
_ = listener.Close()
|
||||
return a.Close()
|
||||
}
|
||||
stopWatching := a.watchShutdown(ctx)
|
||||
defer stopWatching()
|
||||
serve, err := a.prepareTransport()
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
@@ -39,6 +42,9 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
||||
}
|
||||
|
||||
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
||||
if err := a.ValidateHTTP(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.config.ValidateTransport(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -57,6 +63,9 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
||||
switch a.config.Transport {
|
||||
case config.TransportNative:
|
||||
server := native.New(a)
|
||||
// App enforces the service/route policy after routing. Keep only the
|
||||
// supported framing ceiling here, never a hidden smaller transport cap.
|
||||
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
cfg := a.config.HTTP
|
||||
if cfg.ReadHeaderTimeout > 0 {
|
||||
@@ -82,6 +91,7 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
||||
return server.Serve, nil
|
||||
case config.TransportHTTP2:
|
||||
server := h2transport.New(a)
|
||||
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
if a.config.HTTP.IdleTimeout > 0 {
|
||||
server.IdleTimeout = a.config.HTTP.IdleTimeout
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"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/lifecycle"
|
||||
)
|
||||
|
||||
func TestConfiguredTransportLifecycle(t *testing.T) {
|
||||
@@ -269,7 +270,9 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
|
||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 40 * time.Millisecond}))
|
||||
var cleaned atomic.Int32
|
||||
application.OnShutdown(func() error { cleaned.Add(1); return nil })
|
||||
started := make(chan context.Context, 1)
|
||||
release, finished := make(chan struct{}), make(chan struct{})
|
||||
application.GET("/blocked", func(ctx *httpx.Context) error {
|
||||
@@ -304,11 +307,17 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
||||
close(release)
|
||||
t.Fatal("handler did not start")
|
||||
}
|
||||
defer func() { close(release); await(t, finished) }()
|
||||
defer func() {
|
||||
close(release)
|
||||
await(t, finished)
|
||||
if err := application.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||
t.Fatalf("late handler return must not trigger cleanup: %v count=%d", err, cleaned.Load())
|
||||
}
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||
t.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# go-omni CLI
|
||||
|
||||
v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。
|
||||
|
||||
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。
|
||||
|
||||
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
|
||||
|
||||
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
|
||||
@@ -8,13 +12,13 @@ v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 work
|
||||
|
||||
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
||||
|
||||
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||
API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||
|
||||
## 创建项目
|
||||
## 创建 API 项目
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -49,14 +53,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.25.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.28.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.25.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
当前模板与依赖以 v0.28.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
for i := range spec.Errors {
|
||||
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
|
||||
spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params
|
||||
spec.Errors[i].Translation = normalized.Definitions[spec.Errors[i].Code].Translation
|
||||
}
|
||||
if err := validateErrorPolicies(&spec); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,14 +11,15 @@ import (
|
||||
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||
type ErrorDefinition struct {
|
||||
Name string `json:"name"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
Params []apierrors.Parameter `json:"params,omitempty"`
|
||||
Catalog string `json:"catalog,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
Params []apierrors.Parameter `json:"params,omitempty"`
|
||||
Translation *apierrors.Translation `json:"translation,omitempty"`
|
||||
Catalog string `json:"catalog,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
}
|
||||
type AuthDefinition struct {
|
||||
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
||||
|
||||
@@ -49,11 +49,12 @@ func (p *parser) parseErrorCatalog() error {
|
||||
}
|
||||
}
|
||||
type entry struct {
|
||||
Key string `json:"key"`
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
Params []apierrors.Parameter `json:"params"`
|
||||
Key string `json:"key"`
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
Params []apierrors.Parameter `json:"params"`
|
||||
Translation *apierrors.Translation `json:"translation"`
|
||||
}
|
||||
var entries map[string]entry
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
||||
@@ -75,7 +76,7 @@ func (p *parser) parseErrorCatalog() error {
|
||||
if e.Status == 0 {
|
||||
e.Status = defaultStatus
|
||||
}
|
||||
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Catalog: name, Position: pos})
|
||||
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Translation: e.Translation, Catalog: name, Position: pos})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -90,7 +91,7 @@ func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
|
||||
if names != nil && !selected[e.Name] {
|
||||
continue
|
||||
}
|
||||
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params}
|
||||
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params, Translation: e.Translation}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
@@ -22,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||
const minimumVersion = "v0.22.0-preview"
|
||||
const currentVersion = "v0.25.0-preview"
|
||||
const currentVersion = "v0.28.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
@@ -194,6 +195,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
seeds["go.mod"] = data
|
||||
}
|
||||
capabilityModule := moduleData
|
||||
if seeded, ok := seeds["go.mod"]; ok {
|
||||
capabilityModule = seeded
|
||||
}
|
||||
if err := checkI18nRuntime(c.Spec, capabilityModule, directory); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
if err = module.CheckImportPath(o.Module); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
@@ -338,25 +346,7 @@ func moduleFile(o Options) ([]byte, error) {
|
||||
}
|
||||
return []byte(text), nil
|
||||
}
|
||||
func canonical(path string) string {
|
||||
path, _ = filepath.Abs(path)
|
||||
candidate := path
|
||||
var tail []string
|
||||
for {
|
||||
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
resolved = filepath.Join(resolved, tail[i])
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if parent == candidate {
|
||||
return path
|
||||
}
|
||||
tail = append(tail, filepath.Base(candidate))
|
||||
candidate = parent
|
||||
}
|
||||
}
|
||||
func canonical(path string) string { return genfs.Canonical(path) }
|
||||
|
||||
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
|
||||
generated, seeds := map[string][]byte{}, map[string][]byte{}
|
||||
|
||||
57
cmd/go-omni/apigen/i18nVersion.go
Normal file
57
cmd/go-omni/apigen/i18nVersion.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Translation metadata was introduced in this release. Consumers of older
|
||||
// runtimes must upgrade or use an explicitly supported local replacement.
|
||||
const i18nMinimumVersion = "v0.26.0-preview"
|
||||
|
||||
func checkI18nRuntime(spec *Spec, moduleData []byte, directory string) error {
|
||||
needed := false
|
||||
for _, e := range spec.Errors {
|
||||
needed = needed || e.Translation != nil
|
||||
}
|
||||
if !needed {
|
||||
return nil
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version := ""
|
||||
for _, r := range mf.Require {
|
||||
if r.Mod.Path == frameworkModule {
|
||||
version = r.Mod.Version
|
||||
}
|
||||
}
|
||||
for _, r := range mf.Replace {
|
||||
if r.Old.Path != frameworkModule || r.Old.Version != "" && r.Old.Version != version {
|
||||
continue
|
||||
}
|
||||
if r.New.Version != "" {
|
||||
version = r.New.Version
|
||||
continue
|
||||
}
|
||||
root := r.New.Path
|
||||
if !filepath.IsAbs(root) {
|
||||
root = filepath.Join(directory, root)
|
||||
}
|
||||
data, e := os.ReadFile(filepath.Join(root, "errors", "localization.go"))
|
||||
if e == nil && strings.Contains(string(data), "type Translation struct") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("translation metadata requires a local framework with errors.Translation support")
|
||||
}
|
||||
if semver.Compare(version, i18nMinimumVersion) < 0 {
|
||||
return fmt.Errorf("translation metadata requires %s or newer; use --framework-dir for unpublished source", i18nMinimumVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
42
cmd/go-omni/apigen/i18n_test.go
Normal file
42
cmd/go-omni/apigen/i18n_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestI18nTranslationMetadataAndVersionBoundary(t *testing.T) {
|
||||
spec, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref := compiled.Spec.Errors[0].Translation
|
||||
if ref == nil || ref.Namespace != "limits" || ref.Key != "exceeded" {
|
||||
t.Fatal(ref)
|
||||
}
|
||||
if err := checkI18nRuntime(spec, []byte("module example.com/test\ngo 1.22\nrequire "+frameworkModule+" v0.25.0-preview\n"), t.TempDir()); err == nil {
|
||||
t.Fatal("old runtime accepted")
|
||||
}
|
||||
generated, _, err := renderProject(Options{Module: "example.com/test"}, compiled, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(generated["internal/apierrors/errorsGen.go"]), "translation") || !strings.Contains(string(generated["docs/openapi.json"]), "translation") {
|
||||
t.Fatal("translation metadata lost")
|
||||
}
|
||||
bad := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
|
||||
bad = strings.Replace(bad, `"namespace":"limits"`, `"namespace":""`, 1)
|
||||
parsed, err := parseText(t, bad)
|
||||
if err == nil {
|
||||
_, err = Compile(parsed)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("invalid translation reference accepted")
|
||||
}
|
||||
}
|
||||
func TestI18nGeneratedConsumer(t *testing.T) { testCatalogConsumer(t, "i18n.api", "i18n") }
|
||||
@@ -30,12 +30,12 @@ func main(){
|
||||
func run(ctx context.Context,path string)(runErr error){
|
||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||
// Unjoined background work may still log. The owning process exits on this
|
||||
// Unjoined HTTP or background work may still log. The owning process exits on this
|
||||
// error; do not close its borrowed logger before that work has finished.
|
||||
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||
// and closes them exactly once, before closing the borrowed logger.
|
||||
// Finish propagates incomplete HTTP draining: stop background work but retain
|
||||
// shared dependencies when any handler is still running. Otherwise close once.
|
||||
defer func(){runErr=deps.Finish(runErr)}()
|
||||
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
||||
|
||||
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"example.com/errorCatalog/internal/apierrors"
|
||||
"example.com/errorCatalog/internal/handler"
|
||||
"example.com/errorCatalog/internal/svc"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedTranslationAndParameterCompatibility(t *testing.T) {
|
||||
catalog := apierrors.Catalog()
|
||||
source, err := errori18n.Source(catalog, "en")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source = append(source, i18n.Document{Version: 1, Locale: "zh-Hans", Namespace: "limits", Messages: map[string]i18n.Message{"exceeded": {Value: i18n.Pattern{Text: "上限:{max}"}}}})
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, source...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lc := logx.Default()
|
||||
lc.Writer = io.Discard
|
||||
logger, err := logx.New(lc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
a := omni.New(omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(errori18n.New(b, nil)))
|
||||
defer a.Close()
|
||||
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error { return apierrors.LimitExceeded(9223372036854775807) }
|
||||
})
|
||||
if err := handler.RegisterRoutes(a, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/value", nil)
|
||||
r.Header.Set("Accept-Language", "zh-CN")
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, r)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || body["detail"] != "上限:9223372036854775807" || body["code"] != float64(400001) || body["ok"] != false {
|
||||
t.Fatal(w.Code, body)
|
||||
}
|
||||
if apierrors.LimitExceeded(5).Message != "Limit: 5" {
|
||||
t.Fatal("non-HTTP constructor changed")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
var Opened, Closed, DownloadClosed atomic.Int32
|
||||
var SlowReturned atomic.Int32
|
||||
var Started = make(chan struct{}, 1)
|
||||
var Release = make(chan struct{})
|
||||
var Current atomic.Pointer[ServiceContext]
|
||||
|
||||
@@ -71,6 +71,7 @@ type countedReader struct{ io.Reader }
|
||||
|
||||
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
||||
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
||||
defer svc.SlowReturned.Add(1)
|
||||
before := svc.Closed.Load()
|
||||
svc.Started <- struct{}{}
|
||||
<-svc.Release
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"example.com/managed/internal/handler"
|
||||
"example.com/managed/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
@@ -229,3 +230,80 @@ func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
|
||||
t.Fatal("collision leaked resources or started serving", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedIncompleteHTTPDrainRetainsDependencies(t *testing.T) {
|
||||
for _, transport := range []string{"nethttp", "native"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := l.Addr().String()
|
||||
l.Close()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
logFile := filepath.Join(t.TempDir(), "service.log")
|
||||
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address, "transport": transport, "shutdownTimeout": "30ms", "http": map[string]string{"shutdownCancelTimeout": "30ms"}, "log": map[string]any{"file": logFile, "rotation": map[string]any{"enabled": true}}}})
|
||||
if err = os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.Started = make(chan struct{}, 1)
|
||||
svc.Release = make(chan struct{})
|
||||
before := svc.Closed.Load()
|
||||
returned := svc.SlowReturned.Load()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
waitReady(t, "http://"+address, done)
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
requestDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestDone)
|
||||
res, err := client.Get("http://" + address + "/slow")
|
||||
if err == nil {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
}
|
||||
}()
|
||||
await(t, svc.Started)
|
||||
// Capture the channel, because a later test installs its own fixture.
|
||||
release := svc.Release
|
||||
deps := svc.Current.Load()
|
||||
defer func() {
|
||||
close(release)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for svc.SlowReturned.Load() == returned {
|
||||
if time.Now().After(deadline) {
|
||||
t.Error("handler did not exit after test release")
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
// The test owns the controlled handler and has now joined it.
|
||||
_ = deps.Logger.Close()
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err = <-done:
|
||||
if !errors.Is(err, lifecycle.ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("unbounded HTTP shutdown")
|
||||
}
|
||||
if err = deps.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal("deferred Close bypassed retention", err)
|
||||
}
|
||||
if svc.Closed.Load() != before {
|
||||
t.Fatal("generated main closed client during active request")
|
||||
}
|
||||
deps.Logger.Info("retained.http.logger")
|
||||
logs, err := os.ReadFile(logFile)
|
||||
if err != nil || !strings.Contains(string(logs), "retained.http.logger") {
|
||||
t.Fatal("generated main closed logger during active request", err, string(logs))
|
||||
}
|
||||
await(t, requestDone)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
|
||||
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.22.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
|
||||
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
|
||||
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -244,44 +245,13 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
}
|
||||
return result, checkErr
|
||||
}
|
||||
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
||||
// File replacement is atomic; the whole directory is not crash-atomic.
|
||||
var applied []change
|
||||
transaction := make([]genfs.Change, 0, len(changes))
|
||||
for _, c := range changes {
|
||||
if err = safePath(root, c.name); err == nil {
|
||||
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.name)))
|
||||
if c.existed {
|
||||
if e != nil || !bytes.Equal(current, c.before) {
|
||||
err = fmt.Errorf("file changed during generation: %s", c.name)
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
err = fmt.Errorf("file appeared during generation: %s", c.name)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
if c.remove {
|
||||
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.name)))
|
||||
} else {
|
||||
err = atomicWrite(root, c.name, c.after, c.mode)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
for i := len(applied) - 1; i >= 0; i-- {
|
||||
previous := applied[i]
|
||||
var rollback error
|
||||
if previous.existed {
|
||||
rollback = atomicWrite(root, previous.name, previous.before, previous.mode)
|
||||
} else {
|
||||
rollback = os.Remove(filepath.Join(root, filepath.FromSlash(previous.name)))
|
||||
}
|
||||
err = errors.Join(err, rollback)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
applied = append(applied, c)
|
||||
transaction = append(transaction, genfs.Change{Name: c.name, Before: c.before, After: c.after, Existed: c.existed, Remove: c.remove, Mode: c.mode})
|
||||
}
|
||||
return result, nil
|
||||
return result, genfs.Apply(root, transaction)
|
||||
}
|
||||
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
func ownedPath(name string) bool {
|
||||
if name == "internal/config/loadGen.go" {
|
||||
@@ -298,52 +268,7 @@ func ownedPath(name string) bool {
|
||||
}
|
||||
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
|
||||
}
|
||||
func safePath(root, name string) error {
|
||||
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
||||
return fmt.Errorf("api: unsafe output path %q", name)
|
||||
}
|
||||
path := root
|
||||
parts := strings.Split(name, "/")
|
||||
for i, part := range parts {
|
||||
path = filepath.Join(path, part)
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("api: refusing symlink %s", path)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("api: expected directory %s", path)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("api: expected regular file %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func safePath(root, name string) error { return genfs.SafePath(root, name) }
|
||||
func atomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(target), ".apiGen-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err = f.Chmod(mode); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), target)
|
||||
return genfs.AtomicWrite(root, name, data, mode)
|
||||
}
|
||||
|
||||
195
cmd/go-omni/i18n.go
Normal file
195
cmd/go-omni/i18n.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool"
|
||||
)
|
||||
|
||||
type i18nFlags []string
|
||||
|
||||
func (v *i18nFlags) String() string { return strings.Join(*v, ",") }
|
||||
func (v *i18nFlags) Set(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("empty value")
|
||||
}
|
||||
*v = append(*v, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runI18n(args []string) error { return runI18nTo(args, os.Stdout) }
|
||||
func runI18nTo(args []string, w io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("expected check, export, types or extract")
|
||||
}
|
||||
fs := flag.NewFlagSet("i18n "+args[0], flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var resources, namespaces, goFiles i18nFlags
|
||||
configPath := fs.String("config", "", "i18n Config JSON file")
|
||||
locale := fs.String("locale", "", "selected/source locale")
|
||||
catalog := fs.String("catalog", "", "exported errors.Catalog JSON (extract only)")
|
||||
fs.Var(&resources, "resource", "resource JSON file (repeatable)")
|
||||
fs.Var(&namespaces, "namespace", "export namespace (repeatable)")
|
||||
fs.Var(&goFiles, "go", "Go source file (repeatable)")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return errors.New("unexpected positional arguments")
|
||||
}
|
||||
encode := func(v any) error { e := json.NewEncoder(w); e.SetIndent("", " "); return e.Encode(v) }
|
||||
if args[0] == "extract" && *catalog != "" {
|
||||
if len(goFiles) > 0 || len(resources) > 0 || *configPath != "" || len(namespaces) != 1 || *locale == "" {
|
||||
return errors.New("catalog extraction requires --catalog, --locale and exactly one --namespace")
|
||||
}
|
||||
raw, err := readI18nFile(*catalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var c apierrors.Catalog
|
||||
if err := apierrors.DecodeCatalogJSON(raw, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
docs, err := errori18n.Source(errori18n.Bind(c, namespaces[0]), *locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if doc.Namespace == namespaces[0] {
|
||||
return encode(doc)
|
||||
}
|
||||
}
|
||||
return errors.New("catalog has no messages in the selected namespace")
|
||||
}
|
||||
if *catalog != "" {
|
||||
return errors.New("--catalog is only supported by extract")
|
||||
}
|
||||
if args[0] == "extract" {
|
||||
if len(goFiles) == 0 || len(resources) > 0 || len(namespaces) > 0 || *configPath != "" || *locale != "" {
|
||||
return errors.New("Go extraction requires one or more --go files")
|
||||
}
|
||||
out := i18ntool.Extraction{References: []i18ntool.Reference{}, Findings: []i18ntool.Finding{}}
|
||||
for _, path := range goFiles {
|
||||
raw, err := readI18nFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := i18ntool.ExtractGo(path, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.References = append(out.References, result.References...)
|
||||
out.Findings = append(out.Findings, result.Findings...)
|
||||
}
|
||||
return encode(out)
|
||||
}
|
||||
if args[0] != "check" && args[0] != "export" && args[0] != "types" {
|
||||
return errors.New("expected check, export, types or extract")
|
||||
}
|
||||
if *configPath == "" || len(resources) == 0 {
|
||||
return errors.New("--config and at least one --resource are required")
|
||||
}
|
||||
data, err := readI18nFile(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg i18n.Config
|
||||
if err := i18n.DecodeJSON(data, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
docs := []i18n.Document{}
|
||||
total := 0
|
||||
for _, path := range resources {
|
||||
raw, err := readI18nFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += len(raw)
|
||||
if total > 4<<20 {
|
||||
return errors.New("resources exceed 4 MiB")
|
||||
}
|
||||
var doc i18n.Document
|
||||
if err := i18n.DecodeJSON(raw, &doc); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
bundle, err := i18n.New(cfg, docs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "export":
|
||||
if len(goFiles) > 0 {
|
||||
return errors.New("--go is only supported by check/extract")
|
||||
}
|
||||
return bundle.Export(w, *locale, namespaces...)
|
||||
case "types":
|
||||
if *locale != "" || len(goFiles) > 0 {
|
||||
return errors.New("types does not accept --locale or --go")
|
||||
}
|
||||
return bundle.TypeScript(w, namespaces...)
|
||||
case "check":
|
||||
if *locale != "" || len(namespaces) > 0 {
|
||||
return errors.New("check validates all configured locales and namespaces")
|
||||
}
|
||||
findings := bundle.Check()
|
||||
for _, path := range goFiles {
|
||||
raw, e := readI18nFile(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
ex, e := i18ntool.ExtractGo(path, raw)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for _, r := range ex.References {
|
||||
if _, ok := bundle.Schema(i18n.Ref{Namespace: r.Namespace, Key: r.Key}); !ok {
|
||||
findings = append(findings, i18n.Diagnostic{Kind: "missing", Namespace: r.Namespace, Key: r.Key, Problem: fmt.Sprintf("referenced by %s:%d", r.File, r.Line)})
|
||||
}
|
||||
}
|
||||
for _, f := range ex.Findings {
|
||||
findings = append(findings, i18n.Diagnostic{Kind: "dynamic", Problem: fmt.Sprintf("%s:%d: %s", f.File, f.Line, f.Problem)})
|
||||
}
|
||||
}
|
||||
if err := encode(findings); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
return errors.New("internationalization checks failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func readI18nFile(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
s, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.Mode().IsRegular() {
|
||||
return nil, errors.New("expected a regular file")
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > 1<<20 {
|
||||
return nil, errors.New("file exceeds 1 MiB")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
80
cmd/go-omni/i18n_test.go
Normal file
80
cmd/go-omni/i18n_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
)
|
||||
|
||||
func TestI18nCommandsAreReadOnlyAndCheckMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"config.json": `{"sourceLocale":"en","defaultLocale":"en","locales":["en","fr"]}`,
|
||||
"en.json": `{"version":1,"namespace":"ui","locale":"en","messages":{"hello":{"params":{"type":"string"},"value":{"text":"Hello {type}"}}}}`,
|
||||
"fr.json": `{"version":1,"namespace":"ui","locale":"fr","messages":{"hello":{"value":{"text":"Bonjour {type}"}}}}`,
|
||||
"use.go": "package demo\nimport words \"git.yuntuo.hk/k2safe/go-omni/i18n\"\nvar key=words.Ref{Namespace:\"ui\",Key:\"hello\"}\n",
|
||||
}
|
||||
for path, data := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(data), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
base := []string{"--config", filepath.Join(dir, "config.json"), "--resource", filepath.Join(dir, "en.json"), "--resource", filepath.Join(dir, "fr.json")}
|
||||
var out bytes.Buffer
|
||||
args := append([]string{"check"}, base...)
|
||||
args = append(args, "--go", filepath.Join(dir, "use.go"))
|
||||
if err := runI18nTo(args, &out); err != nil || strings.TrimSpace(out.String()) != "[]" {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
out.Reset()
|
||||
args = append([]string{"export"}, base...)
|
||||
args = append(args, "--locale", "fr", "--namespace", "ui")
|
||||
if err := runI18nTo(args, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pack i18n.Pack
|
||||
if err := json.Unmarshal(out.Bytes(), &pack); err != nil || pack.Locale != "fr" || len(pack.Documents) != 2 {
|
||||
t.Fatal(err, pack)
|
||||
}
|
||||
out.Reset()
|
||||
args = append([]string{"types"}, base...)
|
||||
args = append(args, "--namespace", "ui")
|
||||
if err := runI18nTo(args, &out); err != nil || !strings.Contains(out.String(), `"type": string`) {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := runI18nTo([]string{"extract", "--go", filepath.Join(dir, "use.go")}, &out); err != nil || !strings.Contains(out.String(), `"key": "hello"`) {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
for path, want := range files {
|
||||
got, _ := os.ReadFile(filepath.Join(dir, path))
|
||||
if string(got) != want {
|
||||
t.Fatal("command mutated", path)
|
||||
}
|
||||
}
|
||||
out.Reset()
|
||||
args = append([]string{"check"}, base[:4]...)
|
||||
if err := runI18nTo(args, &out); err == nil || !strings.Contains(out.String(), "direct translation missing") {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestI18nCatalogExtractionPreservesLiteralBraces(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "errors.json")
|
||||
if err := os.WriteFile(path, []byte(`{"definitions":{"FIXED":{"code":1,"message":"${NAME}"}}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := runI18nTo([]string{"extract", "--catalog", path, "--namespace", "errors", "--locale", "en"}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var d i18n.Document
|
||||
if err := i18n.DecodeJSON(out.Bytes(), &d); err != nil || !d.Messages["FIXED"].Value.Literal || d.Messages["FIXED"].Value.Text != "${NAME}" {
|
||||
t.Fatal(err, d)
|
||||
}
|
||||
}
|
||||
133
cmd/go-omni/internal/genfs/files.go
Normal file
133
cmd/go-omni/internal/genfs/files.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package genfs shares guarded filesystem operations between project generators.
|
||||
package genfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SafePath(root, name string) error {
|
||||
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
||||
return fmt.Errorf("generation: unsafe output path %q", name)
|
||||
}
|
||||
path := root
|
||||
parts := strings.Split(name, "/")
|
||||
for i, part := range parts {
|
||||
path = filepath.Join(path, part)
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("generation: refusing symlink %s", path)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("generation: expected directory %s", path)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("generation: expected regular file %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func AtomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(target), ".omniGen-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err = f.Chmod(mode); err == nil {
|
||||
_, err = f.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), target)
|
||||
}
|
||||
|
||||
// Change records the preflight snapshot used for conflict detection and rollback.
|
||||
type Change struct {
|
||||
Name string
|
||||
Before, After []byte
|
||||
Existed, Remove bool
|
||||
Mode os.FileMode
|
||||
}
|
||||
|
||||
// Apply replaces individual files atomically and rolls back ordinary write failures.
|
||||
// Callers serialize generators with a project lock. This is not crash-atomic across files.
|
||||
func Apply(root string, changes []Change) error {
|
||||
var applied []Change
|
||||
for _, c := range changes {
|
||||
err := SafePath(root, c.Name)
|
||||
if err == nil {
|
||||
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.Name)))
|
||||
if c.Existed {
|
||||
if e != nil || !bytes.Equal(current, c.Before) {
|
||||
err = fmt.Errorf("file changed during generation: %s", c.Name)
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
err = fmt.Errorf("file appeared during generation: %s", c.Name)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
if c.Remove {
|
||||
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.Name)))
|
||||
} else {
|
||||
err = AtomicWrite(root, c.Name, c.After, c.Mode)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
for i := len(applied) - 1; i >= 0; i-- {
|
||||
previous := applied[i]
|
||||
if e := SafePath(root, previous.Name); e != nil {
|
||||
err = errors.Join(err, e)
|
||||
continue
|
||||
}
|
||||
if previous.Existed {
|
||||
err = errors.Join(err, AtomicWrite(root, previous.Name, previous.Before, previous.Mode))
|
||||
} else {
|
||||
err = errors.Join(err, os.Remove(filepath.Join(root, filepath.FromSlash(previous.Name))))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
applied = append(applied, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Canonical resolves existing parent symlinks even for an output not yet created.
|
||||
func Canonical(path string) string {
|
||||
path, _ = filepath.Abs(path)
|
||||
candidate := path
|
||||
var tail []string
|
||||
for {
|
||||
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
resolved = filepath.Join(resolved, tail[i])
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if parent == candidate {
|
||||
return path
|
||||
}
|
||||
tail = append(tail, filepath.Base(candidate))
|
||||
candidate = parent
|
||||
}
|
||||
}
|
||||
47
cmd/go-omni/internal/genfs/files_test.go
Normal file
47
cmd/go-omni/internal/genfs/files_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package genfs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConflictRollsBackEarlierWrites(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "one"), []byte("before"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "two"), []byte("concurrent"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := Apply(root, []Change{{Name: "one", Before: []byte("before"), After: []byte("after"), Existed: true, Mode: 0600}, {Name: "new", After: []byte("created"), Mode: 0644}, {Name: "two", Before: []byte("stale"), After: []byte("after"), Existed: true, Mode: 0644}})
|
||||
if err == nil {
|
||||
t.Fatal("conflict accepted")
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(root, "one"))
|
||||
info, _ := os.Stat(filepath.Join(root, "one"))
|
||||
if string(data) != "before" || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("rollback did not restore bytes/mode")
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) {
|
||||
t.Fatal("new output survived rollback")
|
||||
}
|
||||
data, _ = os.ReadFile(filepath.Join(root, "two"))
|
||||
if string(data) != "concurrent" {
|
||||
t.Fatal("concurrent edit overwritten")
|
||||
}
|
||||
}
|
||||
func TestUnsafePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"../escape", "a/../../escape", "/outside", "a\\b", ".", "a/../b"} {
|
||||
if SafePath(root, name) == nil {
|
||||
t.Fatal(name)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(t.TempDir(), filepath.Join(root, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if SafePath(root, "link/file") == nil {
|
||||
t.Fatal("followed symlink")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,16 @@ func main() {
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "project":
|
||||
if err := runProject(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni project:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "i18n":
|
||||
if err := runI18n(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni i18n:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "workspace":
|
||||
if err := runWorkspace(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni workspace:", err)
|
||||
@@ -170,6 +180,8 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, "GoOmni project tool")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Usage:")
|
||||
fmt.Fprintln(os.Stderr, " go-omni project <new|generate|check> Generate a transport-independent Go program")
|
||||
fmt.Fprintln(os.Stderr, " go-omni i18n <check|export|types|extract> Validate and export independent language resources")
|
||||
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
||||
@@ -211,6 +223,9 @@ func runAdd(args []string) error {
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(directory, ".goOmni", "projectManifest.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("plain Go projects add packages as ordinary Go code; use project generate for infrastructure")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil {
|
||||
return errors.New("managed API projects add modules through .api imports and api generate")
|
||||
}
|
||||
|
||||
102
cmd/go-omni/project.go
Normal file
102
cmd/go-omni/project.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||
)
|
||||
|
||||
func runProject(args []string) error {
|
||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||
projectUsage()
|
||||
return nil
|
||||
}
|
||||
command := args[0]
|
||||
if command != "new" && command != "generate" && command != "check" {
|
||||
return fmt.Errorf("unknown project command %q", command)
|
||||
}
|
||||
o := projectgen.Options{Check: command == "check"}
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--help" || arg == "-h" {
|
||||
projectUsage()
|
||||
return nil
|
||||
}
|
||||
if arg == "--check" || arg == "--dry-run" {
|
||||
if command != "generate" {
|
||||
return fmt.Errorf("%s belongs to project generate", arg)
|
||||
}
|
||||
if arg == "--check" {
|
||||
o.Check = true
|
||||
} else {
|
||||
o.DryRun = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
if command != "new" || o.Dir != "" {
|
||||
return fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
o.Dir = arg
|
||||
continue
|
||||
}
|
||||
name, value, inline := strings.Cut(arg, "=")
|
||||
switch name {
|
||||
case "--dir", "--module", "--framework-dir", "--framework-version":
|
||||
default:
|
||||
return fmt.Errorf("unknown project option %q", name)
|
||||
}
|
||||
if command != "new" && name != "--dir" {
|
||||
return fmt.Errorf("%s belongs to project new", name)
|
||||
}
|
||||
if !inline {
|
||||
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
i++
|
||||
value = args[i]
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s requires a value", name)
|
||||
}
|
||||
switch name {
|
||||
case "--dir":
|
||||
if o.Dir != "" {
|
||||
return errors.New("project directory specified more than once")
|
||||
}
|
||||
o.Dir = value
|
||||
case "--module":
|
||||
o.Module = value
|
||||
case "--framework-dir":
|
||||
o.FrameworkDir = value
|
||||
case "--framework-version":
|
||||
o.FrameworkVersion = value
|
||||
}
|
||||
}
|
||||
var out projectgen.Output
|
||||
var err error
|
||||
if command == "new" {
|
||||
out, err = projectgen.New(o)
|
||||
} else {
|
||||
out, err = projectgen.Generate(o)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.DryRun {
|
||||
for _, change := range out.Changes {
|
||||
fmt.Printf("%-7s %s\n", change.Action, change.Path)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("project %s: %d file changes\n", command, len(out.Changes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func projectUsage() {
|
||||
fmt.Println("go-omni project new <directory> [--module <module>] [--framework-version <version>|--framework-dir <directory>]")
|
||||
fmt.Println("go-omni project generate [--dir <project>] [--check|--dry-run]")
|
||||
fmt.Println("go-omni project check [--dir <project>]")
|
||||
fmt.Println("Creates a plain Go program with config, logging, dependency lifecycle and an editable Run entry.")
|
||||
}
|
||||
27
cmd/go-omni/project_test.go
Normal file
27
cmd/go-omni/project_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectCLI(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "program")
|
||||
for _, args := range [][]string{{"new", root, "--module", "example.com/program"}, {"generate", "--dir", root, "--dry-run"}, {"generate", "--dir", root}, {"check", "--dir", root}, {"generate", "--dir", root, "--check"}} {
|
||||
if err := runProject(args); err != nil {
|
||||
t.Fatal(args, err)
|
||||
}
|
||||
}
|
||||
if err := runAdd([]string{"item", "--dir", root}); err == nil {
|
||||
t.Fatal("add adopted plain project")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "internal/item")); !os.IsNotExist(err) {
|
||||
t.Fatal("business module generated")
|
||||
}
|
||||
for _, args := range [][]string{{"new"}, {"new", "a", "b"}, {"new", "--api", "x"}, {"check", "--dry-run"}, {"generate", "--check", "--dry-run"}, {"generate", "--module", "x"}, {"new", "--module"}, {"new", "--dir="}, {"new", "--dir", "x", "y"}, {"unknown"}} {
|
||||
if err := runProject(args); err == nil {
|
||||
t.Fatal("accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
190
cmd/go-omni/projectgen/consumer_test.go
Normal file
190
cmd/go-omni/projectgen/consumer_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package projectgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGeneratedProgramConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
root := filepath.Join(t.TempDir(), "program")
|
||||
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
if cli != "" {
|
||||
runCommand(t, "", cli, "project", "new", root, "--module", "example.com/plain")
|
||||
} else {
|
||||
framework, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = New(Options{Dir: root, Module: "example.com/plain", FrameworkDir: framework}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Only project-owned files are customized. All entry/loading/lifecycle code
|
||||
// remains exactly what the local or downloaded CLI generated.
|
||||
svc := get(t, root, "internal/svc/serviceContext.go")
|
||||
svc = strings.Replace(svc, "return nil", "return Initialize(ctx, c)", 1)
|
||||
svc += "\nvar Initialize = func(context.Context, *ServiceContext) error { return nil }\n"
|
||||
put(t, root, "internal/svc/serviceContext.go", svc)
|
||||
put(t, root, "internal/run/run.go", `package run
|
||||
import("context";"errors";"os";"example.com/plain/internal/svc")
|
||||
var Execute=func(ctx context.Context,deps *svc.ServiceContext)error{
|
||||
switch os.Getenv("GO_OMNI_PROGRAM_TEST_MODE") {case "error":return errors.New("process failure marker");case "panic":panic("process panic marker");case "unresponsive":select{}}
|
||||
<-ctx.Done();return nil
|
||||
}
|
||||
func Run(ctx context.Context,deps *svc.ServiceContext)error{return Execute(ctx,deps)}
|
||||
`)
|
||||
put(t, root, "consumer_test.go", get(t, "testdata", "consumer_test.go"))
|
||||
runCommand(t, root, "go", "mod", "tidy")
|
||||
runCommand(t, root, "go", "test", "-race", "-count=1", "./...")
|
||||
runCommand(t, root, "go", "vet", "./...")
|
||||
binary := filepath.Join(root, "program")
|
||||
runCommand(t, root, "go", "build", "-race", "-o", binary, ".")
|
||||
before := snapshot(t, root)
|
||||
for _, mode := range []string{"generate", "check", "dry-run"} {
|
||||
if cli != "" {
|
||||
args := []string{"project", mode, "--dir", root}
|
||||
if mode == "dry-run" {
|
||||
args = []string{"project", "generate", "--dir", root, "--dry-run"}
|
||||
}
|
||||
runCommand(t, root, cli, args...)
|
||||
} else {
|
||||
if _, err := Generate(Options{Dir: root, Check: mode == "check", DryRun: mode == "dry-run"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
after := snapshot(t, root)
|
||||
for name, data := range before {
|
||||
if after[name] != data {
|
||||
t.Fatal("consumer changed during regeneration", name)
|
||||
}
|
||||
}
|
||||
// Validation must not open even the configured log file.
|
||||
logfile := filepath.Join(root, "check-must-not-open.log")
|
||||
cfg := filepath.Join(root, "check.json")
|
||||
raw, _ := json.Marshal(map[string]any{"log": map[string]any{"file": logfile}})
|
||||
put(t, root, "check.json", string(raw))
|
||||
runCommand(t, root, binary, "-config", cfg, "-check-config")
|
||||
if _, err := os.Stat(logfile); !os.IsNotExist(err) {
|
||||
t.Fatal("check-config opened logger", err)
|
||||
}
|
||||
put(t, root, "check.json", `{"log":{"level":"invalid"}}`)
|
||||
cmd := exec.Command(binary, "-config", cfg, "-check-config")
|
||||
cmd.Dir = root
|
||||
if data, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(data), "log.level") {
|
||||
t.Fatal("invalid config accepted", string(data), err)
|
||||
}
|
||||
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
|
||||
testProcess(t, root, binary, sig, "")
|
||||
}
|
||||
for _, mode := range []string{"error", "panic", "unresponsive"} {
|
||||
var sig os.Signal
|
||||
if mode == "unresponsive" {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
testProcess(t, root, binary, sig, mode)
|
||||
}
|
||||
if cli != "" {
|
||||
data := runCommand(t, root, "go", "list", "-m", "-json", frameworkModule)
|
||||
var dep struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &dep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Replace != nil || dep.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer", dep)
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum)
|
||||
}
|
||||
}
|
||||
func runCommand(t *testing.T, dir, name string, args ...string) string {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, output)
|
||||
}
|
||||
t.Logf("%s %v\n%s", name, args, output)
|
||||
return string(output)
|
||||
}
|
||||
func testProcess(t *testing.T, root, binary string, sig os.Signal, mode string) {
|
||||
t.Helper()
|
||||
logfile, err := os.CreateTemp(root, "signal-*.log")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logfile.Close()
|
||||
cfg := filepath.Join(root, "process.json")
|
||||
put(t, root, "process.json", `{"lifecycle":{"stopTimeout":"100ms","drainTimeout":"30ms","cancelTimeout":"100ms"}}`)
|
||||
cmd := exec.Command(binary, "-config", cfg)
|
||||
cmd.Env = append(os.Environ(), "GO_OMNI_PROGRAM_TEST_MODE="+mode)
|
||||
cmd.Dir = root
|
||||
cmd.Stdout = logfile
|
||||
cmd.Stderr = logfile
|
||||
if err = cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
defer func() { _ = cmd.Process.Kill() }()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
data, _ := os.ReadFile(logfile.Name())
|
||||
if strings.Contains(string(data), "program.starting") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("program did not start", string(data))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if path, err := exec.LookPath("lsof"); err == nil {
|
||||
output, _ := exec.Command(path, "-nP", "-a", "-p", strconv.Itoa(cmd.Process.Pid), "-i").CombinedOutput()
|
||||
if strings.Contains(string(output), "TCP") || strings.Contains(string(output), "UDP") {
|
||||
t.Fatal("starter opened network socket", string(output))
|
||||
}
|
||||
}
|
||||
if sig != nil {
|
||||
if err = cmd.Process.Signal(sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err = <-done:
|
||||
data, _ := os.ReadFile(logfile.Name())
|
||||
if mode == "" && err != nil {
|
||||
t.Fatal("signal exit failed", err, string(data))
|
||||
}
|
||||
if mode != "" {
|
||||
exit, ok := err.(*exec.ExitError)
|
||||
if !ok || exit.ExitCode() != 1 {
|
||||
t.Fatal("abnormal process exit status", err, string(data))
|
||||
}
|
||||
}
|
||||
if mode == "unresponsive" && !strings.Contains(string(data), "shared resources retained") {
|
||||
t.Fatal("timeout did not retain resources", string(data))
|
||||
}
|
||||
t.Logf("process mode=%q signal=%v passed", mode, sig)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("signal exit timed out")
|
||||
}
|
||||
}
|
||||
359
cmd/go-omni/projectgen/generate.go
Normal file
359
cmd/go-omni/projectgen/generate.go
Normal file
@@ -0,0 +1,359 @@
|
||||
// Package projectgen generates transport-independent Go programs.
|
||||
package projectgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const currentVersion = "v0.28.0-preview"
|
||||
const minimumVersion = "v0.28.0-preview"
|
||||
const manifestPath = ".goOmni/projectManifest.json"
|
||||
const lockPath = ".goOmni/project.lock"
|
||||
const header = "// Code generated by go-omni project. DO NOT EDIT.\n"
|
||||
|
||||
//go:embed templates/*
|
||||
var templates embed.FS
|
||||
|
||||
var managed = map[string]bool{"main.go": true, "internal/config/loadGen.go": true, "internal/svc/runtimeGen.go": true}
|
||||
var seeds = []string{"go.mod", ".gitignore", "README.md", "etc/config.yaml", "internal/config/config.go", "internal/config/loader.go", "internal/svc/serviceContext.go", "internal/run/run.go"}
|
||||
|
||||
type Options struct {
|
||||
Dir, Module, FrameworkDir, FrameworkVersion string
|
||||
Check, DryRun bool
|
||||
}
|
||||
type FileChange struct{ Path, Action string }
|
||||
type Output struct{ Changes []FileChange }
|
||||
type manifest struct {
|
||||
Version int `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
TemplateVersion string `json:"templateVersion"`
|
||||
Module string `json:"module"`
|
||||
Generated map[string]string `json:"generated"`
|
||||
}
|
||||
|
||||
// New only accepts an empty directory. FrameworkDir is an explicit development opt-in.
|
||||
func New(o Options) (Output, error) {
|
||||
if o.Dir == "" || o.Check || o.DryRun {
|
||||
return Output{}, errors.New("project new requires a directory and does not accept --check/--dry-run")
|
||||
}
|
||||
if entries, err := os.ReadDir(o.Dir); err == nil && len(entries) > 0 {
|
||||
return Output{}, errors.New("project new requires an empty directory")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return Output{}, err
|
||||
}
|
||||
if o.Module == "" {
|
||||
o.Module = filepath.Base(genfs.Canonical(o.Dir))
|
||||
}
|
||||
if err := module.CheckImportPath(o.Module); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
if _, err := moduleFile(o, genfs.Canonical(o.Dir), true); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
return generate(o, true)
|
||||
}
|
||||
func Generate(o Options) (Output, error) { return generate(o, false) }
|
||||
|
||||
func generate(o Options, create bool) (out Output, err error) {
|
||||
if o.Check && o.DryRun {
|
||||
return out, errors.New("--check and --dry-run cannot be combined")
|
||||
}
|
||||
if o.Dir == "" {
|
||||
o.Dir = "."
|
||||
}
|
||||
root, err := filepath.Abs(o.Dir)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if info, e := os.Lstat(root); e == nil {
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return out, errors.New("project directory must be a real directory")
|
||||
}
|
||||
} else if !create || !errors.Is(e, os.ErrNotExist) {
|
||||
return out, e
|
||||
}
|
||||
root = genfs.Canonical(root)
|
||||
for _, name := range append(append([]string{}, seeds...), manifestPath, lockPath) {
|
||||
if err = genfs.SafePath(root, name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for name := range managed {
|
||||
if err = genfs.SafePath(root, name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, other := range []string{".goOmni/apiManifest.json", "api/service.api"} {
|
||||
if _, e := os.Lstat(filepath.Join(root, other)); !errors.Is(e, os.ErrNotExist) {
|
||||
return out, errors.New("project generator requires its own project; existing API layout is not adopted")
|
||||
}
|
||||
}
|
||||
if !o.Check && !o.DryRun {
|
||||
if err = os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
|
||||
return out, err
|
||||
}
|
||||
lock, e := os.OpenFile(filepath.Join(root, lockPath), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if e != nil {
|
||||
return out, fmt.Errorf("acquire project.lock (check for a running generator before removing a stale lock): %w", e)
|
||||
}
|
||||
_, err = fmt.Fprintf(lock, "pid=%d\n", os.Getpid())
|
||||
err = errors.Join(err, lock.Close())
|
||||
defer os.Remove(filepath.Join(root, lockPath))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
var old manifest
|
||||
oldData, readErr := os.ReadFile(filepath.Join(root, manifestPath))
|
||||
if create {
|
||||
if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return out, errors.New("project manifest appeared during creation")
|
||||
}
|
||||
// Recheck after taking the lock; never adopt files created concurrently.
|
||||
entries, e := os.ReadDir(root)
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != ".goOmni" {
|
||||
return out, errors.New("project directory changed during creation")
|
||||
}
|
||||
}
|
||||
entries, e = os.ReadDir(filepath.Join(root, ".goOmni"))
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != "project.lock" {
|
||||
return out, errors.New("project metadata appeared during creation")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if readErr != nil {
|
||||
return out, fmt.Errorf("read project manifest (create with project new first): %w", readErr)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(oldData))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err = decoder.Decode(&old); err != nil {
|
||||
return out, fmt.Errorf("invalid project manifest: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if decoder.Decode(&extra) != io.EOF {
|
||||
return out, errors.New("invalid trailing project manifest data")
|
||||
}
|
||||
if old.Version != 1 || old.Kind != "project" || old.Module == "" || len(old.Generated) != len(managed) || !semver.IsValid(old.TemplateVersion) || semver.Compare(old.TemplateVersion, minimumVersion) > 0 {
|
||||
return out, errors.New("unsupported project manifest; use a compatible CLI")
|
||||
}
|
||||
if o.Module != "" && o.Module != old.Module {
|
||||
return out, errors.New("module differs from project manifest")
|
||||
}
|
||||
o.Module = old.Module
|
||||
for name := range managed {
|
||||
if len(old.Generated[name]) != 64 {
|
||||
return out, fmt.Errorf("invalid manifest ownership for %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if o.Module == "" {
|
||||
o.Module = filepath.Base(root)
|
||||
}
|
||||
if err = module.CheckImportPath(o.Module); err != nil {
|
||||
return out, err
|
||||
}
|
||||
moduleData, err := moduleFile(o, root, create)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
rendered, err := render(o.Module, moduleData)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
next := manifest{Version: 1, Kind: "project", TemplateVersion: minimumVersion, Module: o.Module, Generated: map[string]string{}}
|
||||
var changes []genfs.Change
|
||||
names := make([]string, 0, len(rendered))
|
||||
for name := range rendered {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
before, e := os.ReadFile(target)
|
||||
exists := e == nil
|
||||
if e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||
return out, e
|
||||
}
|
||||
generated := managed[name]
|
||||
if !generated && !create {
|
||||
if !exists {
|
||||
return out, fmt.Errorf("project-owned file %s is missing; restore it explicitly", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if generated {
|
||||
next.Generated[name] = digest(rendered[name])
|
||||
if exists && (create || digest(before) != old.Generated[name]) {
|
||||
return out, fmt.Errorf("generated file %s was edited; move custom code to project-owned files", name)
|
||||
}
|
||||
} else if exists {
|
||||
return out, fmt.Errorf("refusing to adopt %s", name)
|
||||
}
|
||||
if exists && bytes.Equal(before, rendered[name]) {
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0644)
|
||||
if exists {
|
||||
info, e := os.Stat(target)
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
changes = append(changes, genfs.Change{Name: name, Before: before, After: rendered[name], Existed: exists, Mode: mode})
|
||||
}
|
||||
data, err := json.MarshalIndent(next, "", " ")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if !bytes.Equal(oldData, data) {
|
||||
changes = append(changes, genfs.Change{Name: manifestPath, Before: oldData, After: data, Existed: oldData != nil, Mode: 0644})
|
||||
}
|
||||
for _, c := range changes {
|
||||
action := "create"
|
||||
if c.Existed {
|
||||
action = "update"
|
||||
}
|
||||
out.Changes = append(out.Changes, FileChange{c.Name, action})
|
||||
}
|
||||
sort.Slice(out.Changes, func(i, j int) bool { return out.Changes[i].Path < out.Changes[j].Path })
|
||||
if o.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
if o.Check {
|
||||
if len(changes) > 0 {
|
||||
return out, fmt.Errorf("project generation drift: %v", out.Changes)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return out, genfs.Apply(root, changes)
|
||||
}
|
||||
|
||||
func moduleFile(o Options, root string, create bool) ([]byte, error) {
|
||||
if !create {
|
||||
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", data, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mf.Module == nil || mf.Module.Mod.Path != o.Module {
|
||||
return nil, errors.New("go.mod module differs from project manifest")
|
||||
}
|
||||
if o.FrameworkDir != "" || o.FrameworkVersion != "" {
|
||||
return nil, errors.New("framework options belong to project new; update go.mod explicitly")
|
||||
}
|
||||
for _, r := range mf.Require {
|
||||
if r.Mod.Path == frameworkModule && semver.Compare(r.Mod.Version, minimumVersion) >= 0 {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("go.mod must require go-omni %s or newer", minimumVersion)
|
||||
}
|
||||
version := o.FrameworkVersion
|
||||
if version == "" {
|
||||
version = currentVersion
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||
version = info.Main.Version
|
||||
}
|
||||
}
|
||||
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" || semver.Compare(version, minimumVersion) < 0 {
|
||||
return nil, fmt.Errorf("framework version must be %s or newer", minimumVersion)
|
||||
}
|
||||
mf := new(modfile.File)
|
||||
if err := mf.AddModuleStmt(o.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mf.AddGoStmt("1.22.0"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mf.AddRequire(frameworkModule, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
local, err := filepath.Abs(o.FrameworkDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(local, "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := modfile.Parse("go.mod", data, nil)
|
||||
if err != nil || source.Module == nil || source.Module.Mod.Path != frameworkModule {
|
||||
return nil, errors.New("--framework-dir must contain go-omni")
|
||||
}
|
||||
local = genfs.Canonical(local)
|
||||
rel, err := filepath.Rel(root, local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !strings.HasPrefix(rel, ".") {
|
||||
rel = "./" + rel
|
||||
}
|
||||
if err = mf.AddReplace(frameworkModule, "", rel, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return mf.Format()
|
||||
}
|
||||
func render(moduleName string, moduleData []byte) (map[string][]byte, error) {
|
||||
files := map[string][]byte{"go.mod": moduleData}
|
||||
paths := append([]string{}, seeds[1:]...)
|
||||
for name := range managed {
|
||||
paths = append(paths, name)
|
||||
}
|
||||
for _, name := range paths {
|
||||
raw, err := templates.ReadFile("templates/" + strings.ReplaceAll(name, "/", "_") + ".tmpl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := strings.ReplaceAll(string(raw), "\"PROJECT_MODULE\"", strconv.Quote(moduleName))
|
||||
s = strings.ReplaceAll(s, "PROJECT_MODULE/", moduleName+"/")
|
||||
if managed[name] {
|
||||
s = header + s
|
||||
}
|
||||
data := []byte(s)
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
data, err = format.Source(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("format %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
files[name] = data
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
215
cmd/go-omni/projectgen/generate_test.go
Normal file
215
cmd/go-omni/projectgen/generate_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package projectgen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func put(t *testing.T, root, name, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func get(t *testing.T, root, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
func snapshot(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
files := map[string]string{}
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
data, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
files[rel] = string(data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
func fresh(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "program")
|
||||
if _, err := New(Options{Dir: root, Module: "example.com/program"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestOwnershipAndDrift(t *testing.T) {
|
||||
root := fresh(t)
|
||||
files := snapshot(t, root)
|
||||
for name, source := range files {
|
||||
if strings.HasSuffix(name, ".api") || strings.Contains(name, "handler") || strings.Contains(name, "openapi") {
|
||||
t.Fatal("unexpected API output", name)
|
||||
}
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
for _, forbidden := range []string{"net/http", "httpx", "middleware", "ListenAndServe", "database/", "queue/", "task.New"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatal(name, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(files["go.mod"], "replace") || !strings.Contains(files["go.mod"], currentVersion) {
|
||||
t.Fatal("default dependency must be remote and fixed")
|
||||
}
|
||||
for _, name := range seeds {
|
||||
if name != "go.mod" {
|
||||
put(t, root, name, get(t, root, name)+"\n// project edit\n")
|
||||
}
|
||||
}
|
||||
before := snapshot(t, root)
|
||||
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
|
||||
out, err := Generate(o)
|
||||
if err != nil || len(out.Changes) != 0 {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("project files changed")
|
||||
}
|
||||
original := get(t, root, "main.go")
|
||||
put(t, root, "main.go", original+"\n// manual edit\n")
|
||||
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
|
||||
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put(t, root, "main.go", original)
|
||||
if err := os.Remove(filepath.Join(root, "main.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before = snapshot(t, root)
|
||||
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
|
||||
t.Fatal("missing output accepted")
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("check wrote files")
|
||||
}
|
||||
out, err := Generate(Options{Dir: root, DryRun: true})
|
||||
if err != nil || len(out.Changes) != 1 {
|
||||
t.Fatal(out, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("dry-run wrote files")
|
||||
}
|
||||
if _, err = Generate(Options{Dir: root}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if get(t, root, "main.go") != original {
|
||||
t.Fatal("missing output not restored")
|
||||
}
|
||||
if err = os.Remove(filepath.Join(root, "internal/run/run.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), "project-owned") {
|
||||
t.Fatal("silently recreated handwritten file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordedTemplateUpgrade(t *testing.T) {
|
||||
root := fresh(t)
|
||||
var m manifest
|
||||
if err := json.Unmarshal([]byte(get(t, root, manifestPath)), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := get(t, root, "main.go")
|
||||
older := original + "\n// previous generator baseline\n"
|
||||
put(t, root, "main.go", older)
|
||||
m.Generated["main.go"] = digest([]byte(older))
|
||||
data, _ := json.Marshal(m)
|
||||
put(t, root, manifestPath, string(data))
|
||||
before := snapshot(t, root)
|
||||
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
|
||||
t.Fatal("stale baseline accepted")
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("check mutated baseline")
|
||||
}
|
||||
if _, err := Generate(Options{Dir: root}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if get(t, root, "main.go") != original {
|
||||
t.Fatal("template was not updated")
|
||||
}
|
||||
}
|
||||
func TestProjectGuards(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, needle string
|
||||
mutate func(*testing.T, string)
|
||||
}{
|
||||
{"module", "module differs", func(t *testing.T, r string) {
|
||||
put(t, r, "go.mod", strings.Replace(get(t, r, "go.mod"), "example.com/program", "example.com/other", 1))
|
||||
}},
|
||||
{"oldFramework", "must require", func(t *testing.T, r string) {
|
||||
put(t, r, "go.mod", strings.ReplaceAll(get(t, r, "go.mod"), currentVersion, "v0.22.0-preview"))
|
||||
}},
|
||||
{"manifest", "unsupported", func(t *testing.T, r string) { put(t, r, manifestPath, `{"version":1,"kind":"api"}`) }},
|
||||
{"lock", "project.lock", func(t *testing.T, r string) { put(t, r, lockPath, "occupied") }},
|
||||
{"otherGenerator", "existing API", func(t *testing.T, r string) { put(t, r, ".goOmni/apiManifest.json", "{}") }},
|
||||
{"symlink", "symlink", func(t *testing.T, r string) {
|
||||
if e := os.Remove(filepath.Join(r, "main.go")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "outside.go")
|
||||
put(t, filepath.Dir(target), "outside.go", "outside")
|
||||
if e := os.Symlink(target, filepath.Join(r, "main.go")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := fresh(t)
|
||||
tc.mutate(t, root)
|
||||
before := snapshot(t, root)
|
||||
if _, err := Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), tc.needle) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("failed generation changed files")
|
||||
}
|
||||
})
|
||||
}
|
||||
root := fresh(t)
|
||||
before := snapshot(t, root)
|
||||
if _, err := New(Options{Dir: root}); err == nil {
|
||||
t.Fatal("new overwrote project")
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||
t.Fatal("new mutated project")
|
||||
}
|
||||
if _, err := Generate(Options{Dir: t.TempDir(), Check: true}); err == nil {
|
||||
t.Fatal("adopted unmanaged directory")
|
||||
}
|
||||
for _, version := range []string{"v0.27.0-preview", "latest", "v2.0.0", "v0.28.0-preview\nreplace bad"} {
|
||||
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), FrameworkVersion: version}); err == nil {
|
||||
t.Fatal("invalid version", version)
|
||||
}
|
||||
}
|
||||
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), Module: "example.com/a\nreplace bad"}); err == nil {
|
||||
t.Fatal("invalid module")
|
||||
}
|
||||
}
|
||||
6
cmd/go-omni/projectgen/templates/.gitignore.tmpl
Normal file
6
cmd/go-omni/projectgen/templates/.gitignore.tmpl
Normal file
@@ -0,0 +1,6 @@
|
||||
/program
|
||||
/var/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.goOmni/project.lock
|
||||
55
cmd/go-omni/projectgen/templates/README.md.tmpl
Normal file
55
cmd/go-omni/projectgen/templates/README.md.tmpl
Normal file
@@ -0,0 +1,55 @@
|
||||
# Go program
|
||||
|
||||
This project has no HTTP listener, routes, tasks or external dependencies configured.
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
go run . -check-config
|
||||
go run .
|
||||
# SIGINT/SIGTERM stops the program.
|
||||
go build -o program .
|
||||
go-omni project generate --dry-run
|
||||
go-omni project generate
|
||||
go-omni project check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Use the same fixed CLI and framework version. Local --framework-dir is only for
|
||||
explicit source development; normal creation pins the installed CLI version.
|
||||
|
||||
Edit internal/config/config.go (Config/Default/Validate), etc/config.yaml and
|
||||
internal/config/loader.go for optional decoders. JSON/YAML/XML resolve only
|
||||
explicit ${NAME} / ${NAME:-fallback}; fixed values are never implicitly overridden.
|
||||
-check-config validates without opening the logger or dependencies.
|
||||
|
||||
Add dependencies in internal/svc/serviceContext.go, registering OnClose immediately.
|
||||
Register all OnStop/OnDrain hooks in Init, before starting work. Paired hooks use the
|
||||
same name. OnStop stops acquisition; OnDrain returns nil only after all processing,
|
||||
acknowledgements and defers finish. Runtime executes stop, then drain, then resource
|
||||
cleanup in reverse registration order. Callbacks within a phase run concurrently;
|
||||
combine components with ordering requirements in a single hook. Accepted work uses
|
||||
WorkContext; Context is the early stop notification. No automatic retry or restart
|
||||
policy is installed. Adapt external components according to their actual semantics.
|
||||
|
||||
Write program behavior in internal/run/run.go. Return nil when a one-shot run finishes;
|
||||
return an error for fatal failure. The default Run waits for shutdown. Components may
|
||||
call RequestShutdown(err) to report fatal failure. Signal or Run completion starts
|
||||
shutdown; the entry waits for Run itself and registered background work. Do not make
|
||||
Run wait for resource cleanup: cleanup follows Run completion. Do not register hooks
|
||||
concurrently from Run. The drain name go-omni/project/run is reserved.
|
||||
|
||||
Run/Init panics are converted to startup/run failures and registered resources are
|
||||
cleaned safely. Panics in arbitrary project goroutines must be handled by the project.
|
||||
Unresponsive work is bounded by lifecycle timeouts: unfinished work causes
|
||||
ErrResourcesRetained, skips shared resource/logger cleanup and exits unsuccessfully.
|
||||
Go cannot forcibly stop a goroutine. SIGKILL/process crashes cannot run cleanup.
|
||||
|
||||
main.go, internal/config/loadGen.go and internal/svc/runtimeGen.go are generator-owned.
|
||||
Manual edits cause a conflict. Other starter files are project-owned and preserved;
|
||||
missing project-owned files require explicit restoration. Check is read-only and
|
||||
reports missing or stale generated files. It does not compile or execute project code.
|
||||
Commit .goOmni/projectManifest.json with the project. Generation uses a project lock,
|
||||
preflight checks and per-file atomic replacement with rollback for ordinary write errors;
|
||||
it is not crash-atomic across files. Avoid editing files during generation.
|
||||
|
||||
Full guide: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/PROGRAM.md
|
||||
9
cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl
Normal file
9
cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl
Normal file
@@ -0,0 +1,9 @@
|
||||
# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}.
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
output: stderr
|
||||
lifecycle:
|
||||
stopTimeout: 5s
|
||||
drainTimeout: 10s
|
||||
cancelTimeout: 5s
|
||||
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
// Config, Default and Validate are project-owned. Add project settings here.
|
||||
type Config struct {
|
||||
Log logx.Config `json:"log"`
|
||||
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||
}
|
||||
func Default() Config {
|
||||
log:=logx.Default();log.Service="PROJECT_MODULE"
|
||||
return Config{Log:log,Lifecycle:lifecycle.DefaultShutdownConfig()}
|
||||
}
|
||||
func(c Config)Validate()error {
|
||||
if err:=c.Log.Validate();err!=nil{return omniConfig.AtField("log",err)}
|
||||
if err:=c.Lifecycle.Validate();err!=nil{return omniConfig.AtField("lifecycle",err)}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
func Load(path string)(Config,error){
|
||||
loader,err:=NewLoader();if err!=nil{return Config{},err}
|
||||
return omniConfig.LoadProject(path,Default(),loader)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package config
|
||||
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
// NewLoader is project-owned. Register additional decoders here if needed.
|
||||
func NewLoader()(*omniConfig.Loader,error){return omniConfig.NewLoader()}
|
||||
13
cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl
Normal file
13
cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl
Normal file
@@ -0,0 +1,13 @@
|
||||
package run
|
||||
import (
|
||||
"context"
|
||||
"PROJECT_MODULE/internal/svc"
|
||||
)
|
||||
// Run is project-owned. Return nil for normal completion, or an error to fail.
|
||||
// The starter waits for shutdown; a one-shot program can simply return when done.
|
||||
// Register background stop/drain hooks in svc.Init, not concurrently here.
|
||||
// Accepted background work uses deps.WorkContext(), not the early-stop ctx.
|
||||
func Run(ctx context.Context,deps *svc.ServiceContext)error {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package svc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"PROJECT_MODULE/internal/config"
|
||||
)
|
||||
func New(ctx context.Context,logger *logx.Logger,cfg config.Config)(result *ServiceContext,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
|
||||
if logger==nil{return nil,errors.New("service context requires a logger")}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Lifecycle);if err!=nil{return nil,err}
|
||||
c:=&ServiceContext{Runtime:runtime,Config:cfg,Logger:logger}
|
||||
defer func(){
|
||||
if p:=recover();p!=nil{err=fmt.Errorf("dependency initialization panic: %v",p)}
|
||||
if err!=nil{result=nil;err=c.Finish(err)}
|
||||
}()
|
||||
if err=c.Init(c.Context());err!=nil{return nil,err}
|
||||
return c,nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package svc
|
||||
import (
|
||||
"context"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"PROJECT_MODULE/internal/config"
|
||||
)
|
||||
// ServiceContext is project-owned. Add dependencies without global variables.
|
||||
// Runtime owns registered resources; Logger is borrowed from the entry point.
|
||||
type ServiceContext struct {
|
||||
*lifecycle.Runtime
|
||||
Config config.Config
|
||||
Logger *logx.Logger
|
||||
}
|
||||
// Init runs before Run. Register resources immediately with OnClose.
|
||||
// Register paired OnStop/OnDrain hooks before exposing background work.
|
||||
// Use WorkContext for accepted work; Context is the early stop notification.
|
||||
// OnDrain returns nil only after processing and final cleanup have completed.
|
||||
// Complete all registrations here, before Run can race with shutdown.
|
||||
// The drain name "go-omni/project/run" is reserved for the generated entry.
|
||||
func(c *ServiceContext)Init(ctx context.Context)error{return nil}
|
||||
60
cmd/go-omni/projectgen/templates/main.go.tmpl
Normal file
60
cmd/go-omni/projectgen/templates/main.go.tmpl
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"PROJECT_MODULE/internal/config"
|
||||
program "PROJECT_MODULE/internal/run"
|
||||
"PROJECT_MODULE/internal/svc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path:=flag.String("config","etc/config.yaml","configuration file; empty uses Default()")
|
||||
check:=flag.Bool("check-config",false,"validate configuration without initializing 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)}
|
||||
}
|
||||
|
||||
func run(ctx context.Context,path string)(runErr error) {
|
||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||
logger,err:=logx.New(cfg.Log);if err!=nil{return err}
|
||||
var deps *svc.ServiceContext
|
||||
defer func(){
|
||||
if p:=recover();p!=nil{runErr=fmt.Errorf("program initialization panic: %v",p)}
|
||||
if deps!=nil{runErr=deps.Finish(runErr)}
|
||||
// Unjoined work may still use its dependencies and logger. The process exits
|
||||
// unsuccessfully; resource retention never means the work completed.
|
||||
if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}
|
||||
}()
|
||||
deps,err=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
if deps.Context().Err()!=nil{return nil}
|
||||
done:=make(chan struct{})
|
||||
// This reserved drain proves Run has actually returned, including its defers.
|
||||
// Runtime bounds the wait even when Run ignores cancellation.
|
||||
deps.OnDrain("go-omni/project/run",func(context.Context)error{<-done;return nil})
|
||||
logger.InfoContext(deps.Context(),"program.starting")
|
||||
go func(){
|
||||
var result error
|
||||
returned:=false
|
||||
defer func(){
|
||||
if p:=recover();p!=nil{result=fmt.Errorf("program run panic: %v",p)} else if !returned{result=errors.New("program run exited without returning")}
|
||||
deps.RequestShutdown(result)
|
||||
close(done)
|
||||
}()
|
||||
result=program.Run(deps.Context(),deps)
|
||||
returned=true
|
||||
}()
|
||||
<-deps.Context().Done()
|
||||
return nil
|
||||
}
|
||||
283
cmd/go-omni/projectgen/testdata/consumer_test.go
vendored
Normal file
283
cmd/go-omni/projectgen/testdata/consumer_test.go
vendored
Normal file
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/plain/internal/config"
|
||||
program "example.com/plain/internal/run"
|
||||
"example.com/plain/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) {
|
||||
t.Helper()
|
||||
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
t.Cleanup(func() {
|
||||
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||
})
|
||||
}
|
||||
func shortConfig(t *testing.T, logfile string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "config.yaml")
|
||||
text := "log:\n output: discard\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
||||
if logfile != "" {
|
||||
text = "log:\n file: " + logfile + "\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestFiniteRunAndCleanupOrder(t *testing.T) {
|
||||
setup(t)
|
||||
var events []string
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
events = append(events, "init")
|
||||
c.OnClose(func() error { events = append(events, "first"); return nil })
|
||||
c.OnClose(func() error { events = append(events, "second"); return nil })
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(context.Context, *svc.ServiceContext) error {
|
||||
defer func() { events = append(events, "run defer") }()
|
||||
events = append(events, "run")
|
||||
return nil
|
||||
}
|
||||
if err := run(context.Background(), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(events, []string{"init", "run", "run defer", "second", "first"}) {
|
||||
t.Fatal(events)
|
||||
}
|
||||
}
|
||||
func TestInitializationAndRunFailures(t *testing.T) {
|
||||
for _, stage := range []string{"init error", "init panic", "run error", "run panic", "run goexit", "component failure"} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
setup(t)
|
||||
boom := errors.New("failure marker")
|
||||
var closed atomic.Int32
|
||||
var started atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error { closed.Add(1); return nil })
|
||||
if stage == "init error" {
|
||||
return boom
|
||||
}
|
||||
if stage == "init panic" {
|
||||
panic(boom)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
started.Store(true)
|
||||
switch stage {
|
||||
case "run error":
|
||||
return boom
|
||||
case "run panic":
|
||||
panic(boom)
|
||||
case "run goexit":
|
||||
runtime.Goexit()
|
||||
case "component failure":
|
||||
c.RequestShutdown(boom)
|
||||
<-ctx.Done()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
err := run(context.Background(), "")
|
||||
if err == nil || closed.Load() != 1 {
|
||||
t.Fatal(stage, err, closed.Load())
|
||||
}
|
||||
if strings.HasPrefix(stage, "init") && started.Load() {
|
||||
t.Fatal("run started after failed initialization")
|
||||
}
|
||||
if strings.Contains(stage, "error") || stage == "component failure" {
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatal("failure cause lost", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestGracefulStopDrainsAcknowledgementBeforeClose(t *testing.T) {
|
||||
setup(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ready := make(chan struct{})
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var closed, acked atomic.Bool
|
||||
var canceledEarly atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error {
|
||||
if !acked.Load() {
|
||||
return errors.New("closed before acknowledgement")
|
||||
}
|
||||
closed.Store(true)
|
||||
return nil
|
||||
})
|
||||
c.OnStop("component", func(context.Context) error { close(stop); return nil })
|
||||
c.OnDrain("component", func(context.Context) error { <-done; return nil })
|
||||
go func() {
|
||||
defer close(done)
|
||||
<-stop
|
||||
if c.WorkContext().Err() != nil {
|
||||
canceledEarly.Store(true)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
acked.Store(true)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { close(ready); <-ctx.Done(); return nil }
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- run(ctx, "") }()
|
||||
<-ready
|
||||
cancel()
|
||||
if err := <-result; err != nil || !closed.Load() || canceledEarly.Load() {
|
||||
t.Fatal(err, closed.Load(), canceledEarly.Load())
|
||||
}
|
||||
}
|
||||
func TestInitializationFailureDrainsStartedWork(t *testing.T) {
|
||||
setup(t)
|
||||
done := make(chan struct{})
|
||||
stop := make(chan struct{})
|
||||
var closed atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error {
|
||||
select {
|
||||
case <-done:
|
||||
closed.Store(true)
|
||||
return nil
|
||||
default:
|
||||
return errors.New("resource closed while starting work still active")
|
||||
}
|
||||
})
|
||||
c.OnStop("partial", func(context.Context) error { close(stop); return nil })
|
||||
c.OnDrain("partial", func(context.Context) error { <-done; return nil })
|
||||
go func() { defer close(done); <-stop; time.Sleep(10 * time.Millisecond) }()
|
||||
return errors.New("later dependency failed")
|
||||
}
|
||||
if err := run(context.Background(), ""); err == nil || !closed.Load() {
|
||||
t.Fatal(err, closed.Load())
|
||||
}
|
||||
}
|
||||
func TestCancellationGraceJoinsWork(t *testing.T) {
|
||||
setup(t)
|
||||
done := make(chan struct{})
|
||||
var closed atomic.Bool
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
c.OnClose(func() error { closed.Store(true); return nil })
|
||||
c.OnStop("cooperative", func(context.Context) error { return nil })
|
||||
c.OnDrain("cooperative", func(context.Context) error { <-done; return nil })
|
||||
go func() { <-c.WorkContext().Done(); close(done) }()
|
||||
return nil
|
||||
}
|
||||
err := run(context.Background(), shortConfig(t, ""))
|
||||
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) || !closed.Load() {
|
||||
t.Fatal(err, closed.Load())
|
||||
}
|
||||
}
|
||||
func TestUnjoinedWorkRetainsDependenciesAndLogger(t *testing.T) {
|
||||
for _, which := range []string{"run", "background", "init failure"} {
|
||||
t.Run(which, func(t *testing.T) {
|
||||
setup(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
release := make(chan struct{})
|
||||
joined := make(chan struct{})
|
||||
ready := make(chan struct{})
|
||||
var closed atomic.Bool
|
||||
var held *svc.ServiceContext
|
||||
logfile := filepath.Join(t.TempDir(), "retained.log")
|
||||
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
held = c
|
||||
c.OnClose(func() error { closed.Store(true); return nil })
|
||||
if which != "run" {
|
||||
c.OnStop("stuck", func(context.Context) error { return nil })
|
||||
c.OnDrain("stuck", func(context.Context) error { <-joined; return nil })
|
||||
go func() { defer close(joined); <-release; c.Logger.Info("after shutdown deadline") }()
|
||||
}
|
||||
if which == "init failure" {
|
||||
return errors.New("initialization failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||
close(ready)
|
||||
if which == "run" {
|
||||
defer close(joined)
|
||||
<-release
|
||||
c.Logger.Info("after shutdown deadline")
|
||||
} else {
|
||||
<-ctx.Done()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- run(ctx, shortConfig(t, logfile)) }()
|
||||
if which != "init failure" {
|
||||
<-ready
|
||||
cancel()
|
||||
}
|
||||
var err error
|
||||
select {
|
||||
case err = <-result:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown hung")
|
||||
}
|
||||
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
||||
t.Fatal(err, closed.Load())
|
||||
}
|
||||
close(release)
|
||||
<-joined
|
||||
// In the run case the entry's completion notification follows the user defer.
|
||||
// Close is idempotent and must retain resources even after late completion.
|
||||
if err = held.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
||||
t.Fatal("retention was lost", err)
|
||||
}
|
||||
data, _ := os.ReadFile(logfile)
|
||||
if !strings.Contains(string(data), "after shutdown deadline") {
|
||||
t.Fatal("logger closed while work still running", string(data))
|
||||
}
|
||||
_ = held.Logger.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestExplicitConfigurationAndPreflight(t *testing.T) {
|
||||
setup(t)
|
||||
t.Setenv("PLAIN_LEVEL", "debug")
|
||||
t.Setenv("LOG_LEVEL", "error")
|
||||
p := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(p, []byte("log:\n level: ${PLAIN_LEVEL}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil || cfg.Log.Level != "debug" {
|
||||
t.Fatal(cfg.Log.Level, err)
|
||||
}
|
||||
if err = os.WriteFile(p, []byte("log:\n level: info\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err = config.Load(p)
|
||||
if err != nil || cfg.Log.Level != "info" {
|
||||
t.Fatal("implicit environment override", err)
|
||||
}
|
||||
var initCalled atomic.Bool
|
||||
svc.Initialize = func(context.Context, *svc.ServiceContext) error { initCalled.Store(true); return nil }
|
||||
if err = os.WriteFile(p, []byte("lifecycle:\n drainTimeout: -1s\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = run(context.Background(), p); err == nil || initCalled.Load() {
|
||||
t.Fatal("invalid configuration initialized dependencies", err)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ 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.25.0-preview"
|
||||
const defaultFrameworkVersion = "v0.28.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.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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/i18n/httpi18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
@@ -47,6 +48,8 @@ type Config struct {
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
LogInstance *logx.Logger `json:"-"`
|
||||
LanguagePolicy *httpi18n.Policy `json:"-"`
|
||||
ErrorLocalizer apierrors.Localizer `json:"-"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
@@ -235,7 +238,8 @@ func WithLogConfig(settings logx.Config) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithShutdownTimeout controls how long RunContext waits for active requests.
|
||||
// WithShutdownTimeout controls the graceful HTTP drain budget. After timeout,
|
||||
// HTTP.ShutdownCancelTimeout separately bounds the wait for canceled handlers.
|
||||
func WithShutdownTimeout(timeout time.Duration) Option {
|
||||
return func(cfg *Config) {
|
||||
if timeout > 0 {
|
||||
@@ -253,6 +257,15 @@ func WithErrors(catalog apierrors.Catalog) Option {
|
||||
// WithLogInstance borrows a logger for this App without changing process logging.
|
||||
func WithLogInstance(logger *logx.Logger) Option { return func(c *Config) { c.LogInstance = logger } }
|
||||
|
||||
// WithLanguagePolicy borrows an immutable HTTP language policy. Resource loading
|
||||
// remains explicit in the project; configuration strings are not dictionaries.
|
||||
func WithLanguagePolicy(policy *httpi18n.Policy) Option {
|
||||
return func(c *Config) { c.LanguagePolicy = policy }
|
||||
}
|
||||
func WithErrorLocalizer(localizer apierrors.Localizer) Option {
|
||||
return func(c *Config) { c.ErrorLocalizer = localizer }
|
||||
}
|
||||
|
||||
func WithRequestPolicy(policy contract.RequestPolicy) Option {
|
||||
return func(c *Config) { c.Request = policy }
|
||||
}
|
||||
|
||||
@@ -3,17 +3,28 @@ package config
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||
)
|
||||
|
||||
// HTTPConfig controls built-in HTTP servers. Positive values override the
|
||||
// selected transport's defaults; zero keeps its existing behavior. Timeouts
|
||||
// follow that transport's semantics, not a handler execution deadline.
|
||||
// HTTPConfig controls HTTP serving. Network timeouts follow the selected
|
||||
// transport's semantics, not a handler execution deadline. Body limits and
|
||||
// shutdown cancellation grace also apply to App used with an external server.
|
||||
type HTTPConfig struct {
|
||||
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"`
|
||||
ReadTimeout time.Duration `json:"readTimeout"`
|
||||
WriteTimeout time.Duration `json:"writeTimeout"`
|
||||
IdleTimeout time.Duration `json:"idleTimeout"`
|
||||
MaxHeaderBytes int `json:"maxHeaderBytes"`
|
||||
// DefaultMaxBodyBytes applies to App routes without an explicit limit.
|
||||
// Zero uses 8 MiB, or the hard limit if smaller. Route limits may override it.
|
||||
DefaultMaxBodyBytes int64 `json:"defaultMaxBodyBytes"`
|
||||
// MaxBodyBytes is an optional service hard limit. Zero adds no hard limit
|
||||
// below the framework's supported maximum of 1 TiB.
|
||||
MaxBodyBytes int64 `json:"maxBodyBytes"`
|
||||
// ShutdownCancelTimeout bounds the wait for handlers after cancellation.
|
||||
// Zero uses 5s. Resources are retained if handlers have not returned.
|
||||
ShutdownCancelTimeout time.Duration `json:"shutdownCancelTimeout"`
|
||||
// ErrorLog is a Go-only hook for the nethttp server's internal diagnostics.
|
||||
ErrorLog *log.Logger `json:"-"`
|
||||
}
|
||||
@@ -27,6 +38,7 @@ func (c HTTPConfig) Validate() error {
|
||||
{"readTimeout", c.ReadTimeout},
|
||||
{"writeTimeout", c.WriteTimeout},
|
||||
{"idleTimeout", c.IdleTimeout},
|
||||
{"shutdownCancelTimeout", c.ShutdownCancelTimeout},
|
||||
} {
|
||||
if field.value < 0 {
|
||||
return &ConfigError{Field: "http." + field.name, Message: "must be nonnegative"}
|
||||
@@ -35,11 +47,22 @@ func (c HTTPConfig) Validate() error {
|
||||
if c.MaxHeaderBytes < 0 {
|
||||
return &ConfigError{Field: "http.maxHeaderBytes", Message: "must be nonnegative"}
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value int64
|
||||
}{{"defaultMaxBodyBytes", c.DefaultMaxBodyBytes}, {"maxBodyBytes", c.MaxBodyBytes}} {
|
||||
if field.value < 0 || field.value > httplimits.MaxBodyBytes {
|
||||
return &ConfigError{Field: "http." + field.name, Message: "must be 0..1 TiB (bytes)"}
|
||||
}
|
||||
}
|
||||
if c.MaxBodyBytes > 0 && c.DefaultMaxBodyBytes > c.MaxBodyBytes {
|
||||
return &ConfigError{Field: "http.defaultMaxBodyBytes", Message: "must not exceed http.maxBodyBytes"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithHTTPConfig sets the built-in server's HTTP options. RunWith uses a
|
||||
// caller-owned Runner and does not apply these options to that runner.
|
||||
// WithHTTPConfig sets HTTP options. RunWith leaves network configuration to the
|
||||
// caller-owned Runner, but App body limits and cancellation grace still apply.
|
||||
func WithHTTPConfig(settings HTTPConfig) Option {
|
||||
return func(cfg *Config) { cfg.HTTP = settings }
|
||||
}
|
||||
|
||||
@@ -93,3 +93,40 @@ func TestHTTPInvalidAndUnsupportedSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPBodyAndCancellationSettings(t *testing.T) {
|
||||
for _, cfg := range []config.HTTPConfig{
|
||||
{DefaultMaxBodyBytes: -1}, {MaxBodyBytes: -1}, {DefaultMaxBodyBytes: 1<<40 + 1}, {MaxBodyBytes: 1<<40 + 1},
|
||||
{DefaultMaxBodyBytes: 1024, MaxBodyBytes: 512}, {ShutdownCancelTimeout: -time.Second},
|
||||
} {
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatalf("invalid HTTP config accepted: %+v", cfg)
|
||||
}
|
||||
}
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
cfg := config.Default()
|
||||
cfg.Transport = transport
|
||||
cfg.TLS = config.TLSConfig{CertFile: "c", KeyFile: "k"}
|
||||
cfg.HTTP = config.HTTPConfig{DefaultMaxBodyBytes: 1 << 20, MaxBodyBytes: 16 << 20, ShutdownCancelTimeout: time.Second}
|
||||
if err := cfg.ValidateTransport(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("OMNI_BODY_HARD", "16777216")
|
||||
for _, file := range []struct{ name, body string }{
|
||||
{"config.json", `{"http":{"defaultMaxBodyBytes":1048576,"maxBodyBytes":"${OMNI_BODY_HARD}","shutdownCancelTimeout":"250ms"}}`},
|
||||
{"config.yaml", "http:\n defaultMaxBodyBytes: 1048576\n maxBodyBytes: ${OMNI_BODY_HARD}\n shutdownCancelTimeout: 250ms\n"},
|
||||
} {
|
||||
path := filepath.Join(t.TempDir(), file.name)
|
||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.HTTP.DefaultMaxBodyBytes != 1<<20 || cfg.HTTP.MaxBodyBytes != 16<<20 || cfg.HTTP.ShutdownCancelTimeout != 250*time.Millisecond {
|
||||
t.Fatalf("loaded %+v", cfg.HTTP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +380,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) {
|
||||
object[name] = value
|
||||
}
|
||||
end, err := dec.Token()
|
||||
if err != nil || end != json.Delim('}') {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end != json.Delim('}') {
|
||||
return nil, errors.New("unterminated object")
|
||||
}
|
||||
return object, nil
|
||||
@@ -394,7 +397,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) {
|
||||
items = append(items, v)
|
||||
}
|
||||
end, err := dec.Token()
|
||||
if err != nil || end != json.Delim(']') {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end != json.Delim(']') {
|
||||
return nil, errors.New("unterminated array")
|
||||
}
|
||||
return items, nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package contract
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
)
|
||||
|
||||
type byteReader struct{ r io.Reader }
|
||||
@@ -34,6 +36,17 @@ func TestBodyPolicyPropagatesOuterLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONClosingDelimiterPreservesReaderCause(t *testing.T) {
|
||||
cause := errors.New("body reader failed")
|
||||
for _, prefix := range []string{"{", "[", `{"value":[]`, `[{}`} {
|
||||
reader := io.MultiReader(byteReader{strings.NewReader(prefix)}, iotest.ErrReader(cause))
|
||||
_, err := decodeValue(json.NewDecoder(reader), 0)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("prefix %q: lost reader cause: %v", prefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictRequestPolicy(t *testing.T) {
|
||||
type request struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 统一错误目录
|
||||
|
||||
v0.26 新增的通用多语言适配见 [i18n](../i18n/README.md)。目录条目可显式引用 `translation: {namespace, key}`;`errori18n.Bind/Source` 支持从同一份 Go 目录生成源词典。翻译只影响公开展示,不改变业务码、HTTP 状态、失败语义、参数契约或 cause。没有引用或没有启用适配时沿用现有行为;幂等重放保留首次语言。该能力需要 v0.26.0-preview 或更新版本。
|
||||
|
||||
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
|
||||
|
||||
## 基本规则
|
||||
|
||||
@@ -9,16 +9,19 @@ import (
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Definition belongs to the project. Code is the public string/integer code;
|
||||
// the Catalog map key is the distinct logical identifier used by ByCode.
|
||||
// Nil Code retains legacy response code mapping (useful when migrating).
|
||||
type Definition struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Params []Parameter `json:"params,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Params []Parameter `json:"params,omitempty"`
|
||||
Translation *Translation `json:"translation,omitempty"`
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
@@ -99,6 +102,10 @@ func (c Catalog) Clone() Catalog {
|
||||
out := c
|
||||
out.Definitions = make(map[string]Definition, len(c.Definitions))
|
||||
for key, d := range c.Definitions {
|
||||
if d.Translation != nil {
|
||||
ref := *d.Translation
|
||||
d.Translation = &ref
|
||||
}
|
||||
if d.Params != nil {
|
||||
params := make([]Parameter, len(d.Params))
|
||||
copy(params, d.Params)
|
||||
@@ -126,6 +133,13 @@ func NewRegistry(c Catalog) (*Registry, error) {
|
||||
}
|
||||
messages := map[string][]messagePart{}
|
||||
for key, d := range c.Definitions {
|
||||
if d.Translation != nil {
|
||||
for _, value := range []string{d.Translation.Namespace, d.Translation.Key} {
|
||||
if value == "" || len(value) > 256 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.IndexFunc(value, unicode.IsControl) >= 0 {
|
||||
return nil, fmt.Errorf("errors: %s has invalid translation reference", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
|
||||
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
|
||||
}
|
||||
|
||||
46
errors/localization.go
Normal file
46
errors/localization.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package apierrors
|
||||
|
||||
import "context"
|
||||
|
||||
// Translation is an explicit reference into an independently owned dictionary.
|
||||
// It does not change the logical error key, status, public code or parameters.
|
||||
type Translation struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// Localizer is an optional presentation adapter. false preserves the original
|
||||
// public message. Implementations must support concurrent calls and must not
|
||||
// mutate registry or failure, or expose causes/parameter values in diagnostics.
|
||||
type Localizer interface {
|
||||
Localize(context.Context, *Registry, *APIError) (string, bool)
|
||||
}
|
||||
|
||||
// Definition returns detached metadata for the effective catalog scope.
|
||||
func (r *Registry) Definition(key string) (Definition, bool) {
|
||||
if r == nil {
|
||||
return Definition{}, false
|
||||
}
|
||||
d, ok := r.catalog.Definitions[key]
|
||||
if d.Params != nil {
|
||||
d.Params = append([]Parameter{}, d.Params...)
|
||||
}
|
||||
if d.Translation != nil {
|
||||
ref := *d.Translation
|
||||
d.Translation = &ref
|
||||
}
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// Parameters returns detached public template arguments. This is for explicit
|
||||
// presentation adapters, not automatic serialization into an API response.
|
||||
func (e *APIError) Parameters() map[string]any {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(e.params))
|
||||
for k, v := range e.params {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
34
examples/contracts/i18n.api
Normal file
34
examples/contracts/i18n.api
Normal file
@@ -0,0 +1,34 @@
|
||||
syntax = "v1"
|
||||
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"LimitExceeded": {
|
||||
"key": "LIMIT_EXCEEDED", "code": 400001,
|
||||
"message": "Limit: {max}",
|
||||
"params": [{"name":"max","type":"int64","example":100}],
|
||||
"translation": {"namespace":"limits","key":"exceeded"}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
body: {"code":"${code}", "detail":"${message}", "ok":"${ok}"}
|
||||
)
|
||||
|
||||
type ValueResponse {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
@server (
|
||||
group: limits
|
||||
response: CommonResponse
|
||||
errors: LimitExceeded
|
||||
)
|
||||
service localized-api {
|
||||
@handler value
|
||||
get /value returns (ValueResponse)
|
||||
}
|
||||
15
examples/httpSafety/README.md
Normal file
15
examples/httpSafety/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# HTTP 限额与停机
|
||||
|
||||
从仓库根目录运行 `go run ./examples/httpSafety`,可用 `-addr 127.0.0.1:18080` 指定端口。仅使用本机 HTTP 和模拟收尾,不连接数据库或其他服务。
|
||||
|
||||
上传验证:
|
||||
|
||||
```sh
|
||||
dd if=/dev/zero bs=1048576 count=9 2>/dev/null | curl --data-binary @- http://127.0.0.1:8080/upload
|
||||
```
|
||||
|
||||
普通路由默认 1 MiB,上传路由允许 16 MiB,服务硬上限 20 MiB;以上请求返回已读取的 9437184 字节。将上传路由改为 21 MiB 后,启动会报告该路由与服务硬上限冲突。实际文件上传须额外预留 multipart 字段/边界开销,并配置文件大小限制。
|
||||
|
||||
停机验证:另一个终端执行 `curl http://127.0.0.1:8080/work`,随后在服务终端按 Ctrl-C。先等待 1s 排空,再取消请求,处理器模拟 100ms 收尾;日志中先出现 `request cleanup completed`,再出现资源关闭记录。因为用尽排空预算,本次退出仍返回超时错误。
|
||||
|
||||
若处理器不在取消收尾窗口内退出,App 和 `deps.Finish(runErr)` 返回 `lifecycle.ErrResourcesRetained` 并跳过资源关闭。真实项目拥有的日志也必须在该错误下保留,生成入口已处理。进程应非零退出;库不自行退出进程,也不强杀 goroutine。
|
||||
62
examples/httpSafety/main.go
Normal file
62
examples/httpSafety/main.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// This example uses only local HTTP and simulated work; no external services.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
func main() {
|
||||
address := flag.String("addr", "127.0.0.1:8080", "local listen address")
|
||||
flag.Parse()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, *address); err != nil {
|
||||
log.Print(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, address string) (runErr error) {
|
||||
deps := lifecycle.NewRuntime(ctx)
|
||||
deps.OnClose(func() error { log.Print("shared resource closed after requests completed"); return nil })
|
||||
defer func() { runErr = deps.Finish(runErr) }()
|
||||
app := omni.New(omni.WithHTTPConfig(omni.HTTPConfig{
|
||||
DefaultMaxBodyBytes: 1 << 20,
|
||||
MaxBodyBytes: 20 << 20,
|
||||
ShutdownCancelTimeout: time.Second,
|
||||
}), omni.WithShutdownTimeout(time.Second))
|
||||
defer func() { runErr = errors.Join(runErr, app.Close()) }()
|
||||
app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, func(c *omni.Context) error {
|
||||
n, err := io.Copy(io.Discard, c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(map[string]int64{"bytes": n})
|
||||
})
|
||||
app.GET("/work", func(c *omni.Context) error {
|
||||
log.Print("request accepted")
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
return c.OK("finished")
|
||||
case <-c.Context().Done():
|
||||
// Simulate bounded rollback/cleanup after cancellation. Dependencies
|
||||
// remain available until this handler and its defers have returned.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
log.Print("request cleanup completed")
|
||||
return c.Context().Err()
|
||||
}
|
||||
})
|
||||
log.Printf("listening on %s; POST /upload or GET /work, then Ctrl-C", address)
|
||||
return app.RunContext(deps.Context(), address)
|
||||
}
|
||||
28
examples/i18n/README.md
Normal file
28
examples/i18n/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 独立国际化、HTTP 和前端示例
|
||||
|
||||
不依赖数据库、其他项目或外部服务。默认作为 CLI 运行;同一 Bundle 也接入 HTTP 错误和前端导出。
|
||||
|
||||
```sh
|
||||
go run ./examples/i18n
|
||||
go run ./examples/i18n -serve 127.0.0.1:8098
|
||||
```
|
||||
|
||||
访问 `http://127.0.0.1:8098/`,输入草稿并切换语言。只更新标题、标签和按钮文字;输入内容、提交次数和表单节点保留。浏览器模拟 `/locales/zh-Hans.json` 加载失败时保留当前语言;快速连续切换不会被旧加载覆盖。
|
||||
|
||||
```sh
|
||||
curl -i -H 'Accept-Language: zh-CN' http://127.0.0.1:8098/api/limit
|
||||
```
|
||||
|
||||
返回 HTTP 409,业务码 400001,中文公开提示;改成 `en` 时只改变展示文案。静态页面和语言包使用直接输出,不受公共响应格式包装。
|
||||
|
||||
项目管理普通 UI 词典;错误源文案只在 Go Catalog 中维护,`errori18n.Source` 在装配时提供源词典,JSON 只维护中文译文。所有语言包显式嵌入,不受进程工作目录或环境变量影响。
|
||||
|
||||
```sh
|
||||
go run ./examples/i18n -export-locale zh-Hans
|
||||
go run ./examples/i18n -catalog > /tmp/omni-i18n-errors.json
|
||||
go run ./cmd/go-omni i18n extract --catalog /tmp/omni-i18n-errors.json \
|
||||
--locale en --namespace errors
|
||||
go test ./examples/i18n
|
||||
```
|
||||
|
||||
前端 Pack 只导出 `ui` 命名空间,不把错误业务码、HTTP 状态、内部 cause 或未选择的文案混进去。更多检查与类型导出命令见 [通用说明](../../i18n/README.md)。
|
||||
6
examples/i18n/config.json
Normal file
6
examples/i18n/config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sourceLocale": "en",
|
||||
"defaultLocale": "en",
|
||||
"locales": ["en", "zh-Hans"],
|
||||
"fallbacks": {"zh-Hans": ["en"]}
|
||||
}
|
||||
46
examples/i18n/index.html
Normal file
46
examples/i18n/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoOmni language example</title>
|
||||
<style>
|
||||
body{font:17px system-ui,sans-serif;max-width:620px;margin:60px auto;padding:0 24px;color:#172c36;background:#f6f8fa}h1{font-size:30px}label{display:block;margin:24px 0 8px}input,select,button{font:inherit;padding:10px;border:1px solid #9babb3;border-radius:6px}input{box-sizing:border-box;width:100%}button{margin-top:16px;background:#17627a;color:white}p{min-height:24px}#status{color:#a12626}
|
||||
</style>
|
||||
<main>
|
||||
<select id="language" aria-label="Language"><option value="en">English</option><option value="zh-Hans">简体中文</option></select>
|
||||
<h1 id="title">Language example</h1>
|
||||
<form id="form">
|
||||
<label id="draftLabel" for="draft">Your draft</label>
|
||||
<input id="draft" name="draft" autocomplete="off">
|
||||
<button id="submit" type="submit">Submit demo</button>
|
||||
</form>
|
||||
<p id="count" aria-live="polite"></p>
|
||||
<p id="status" role="status"></p>
|
||||
</main>
|
||||
<script type="module">
|
||||
import {createLanguageController, fetchPack} from '/runtime.mjs';
|
||||
const select=document.querySelector('#language'), status=document.querySelector('#status');
|
||||
const controller=createLanguageController(await fetchPack('/locales/en.json'), locale=>fetchPack(`/locales/${encodeURIComponent(locale)}.json`));
|
||||
let count=0;
|
||||
function paint(){
|
||||
const {t,locale}=controller.current;
|
||||
document.documentElement.lang=locale;
|
||||
document.querySelector('#title').textContent=t('ui','title');
|
||||
document.querySelector('#draftLabel').textContent=t('ui','draft');
|
||||
document.querySelector('#submit').textContent=t('ui','submit');
|
||||
document.querySelector('#count').textContent=t('ui','submitted',{count});
|
||||
select.value=locale;
|
||||
}
|
||||
select.addEventListener('change',async()=>{
|
||||
status.textContent='';
|
||||
try{if(await controller.setLanguage(select.value))paint();}
|
||||
catch{select.value=controller.current.locale;status.textContent='Language could not be loaded. / 语言加载失败。';}
|
||||
});
|
||||
document.querySelector('#form').addEventListener('submit',async event=>{
|
||||
event.preventDefault();
|
||||
const response=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','Accept-Language':controller.current.locale},body:JSON.stringify({draft:document.querySelector('#draft').value})});
|
||||
if(response.ok){count++;paint();}
|
||||
});
|
||||
paint();
|
||||
</script>
|
||||
</html>
|
||||
8
examples/i18n/locales/errors.zh-Hans.json
Normal file
8
examples/i18n/locales/errors.zh-Hans.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "errors",
|
||||
"locale": "zh-Hans",
|
||||
"messages": {
|
||||
"LIMIT_EXCEEDED": {"value": {"text": "数量不能超过 {max}"}}
|
||||
}
|
||||
}
|
||||
15
examples/i18n/locales/ui.en.json
Normal file
15
examples/i18n/locales/ui.en.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "ui",
|
||||
"locale": "en",
|
||||
"messages": {
|
||||
"title": {"value": {"text": "Language example"}},
|
||||
"draft": {"value": {"text": "Your draft"}},
|
||||
"submit": {"value": {"text": "Submit demo"}},
|
||||
"greeting": {"params": {"name": "string"}, "value": {"text": "Hello, {name}"}},
|
||||
"submitted": {"params": {"count": "integer"}, "value": {"plural": "count", "cases": {
|
||||
"one": {"text": "Submitted {count} time"},
|
||||
"other": {"text": "Submitted {count} times"}
|
||||
}}}
|
||||
}
|
||||
}
|
||||
12
examples/i18n/locales/ui.zh-Hans.json
Normal file
12
examples/i18n/locales/ui.zh-Hans.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "ui",
|
||||
"locale": "zh-Hans",
|
||||
"messages": {
|
||||
"title": {"value": {"text": "语言切换示例"}},
|
||||
"draft": {"value": {"text": "你的草稿"}},
|
||||
"submit": {"value": {"text": "提交演示"}},
|
||||
"greeting": {"value": {"text": "你好,{name}"}},
|
||||
"submitted": {"value": {"plural": "count", "cases": {"other": {"text": "已提交 {count} 次"}}}}
|
||||
}
|
||||
}
|
||||
135
examples/i18n/main.go
Normal file
135
examples/i18n/main.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
browseri18n "git.yuntuo.hk/k2safe/go-omni/i18n/browser"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
//go:embed config.json locales/*.json index.html
|
||||
var resources embed.FS
|
||||
|
||||
func catalog() apierrors.Catalog {
|
||||
return errori18n.Bind(apierrors.Catalog{Definitions: map[string]apierrors.Definition{
|
||||
"LIMIT_EXCEEDED": {Status: 409, Code: 400001, Message: "Count must not exceed {max}", Params: []apierrors.Parameter{{Name: "max", Type: "int64"}}},
|
||||
}}, "errors")
|
||||
}
|
||||
|
||||
func bundle() (*i18n.Bundle, error) {
|
||||
raw, err := resources.ReadFile("config.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg i18n.Config
|
||||
if err := i18n.DecodeJSON(raw, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs, err := i18n.LoadFS(resources, "locales/ui.en.json", "locales/ui.zh-Hans.json", "locales/errors.zh-Hans.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := errori18n.Source(catalog(), cfg.SourceLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs = append(docs, source...)
|
||||
return i18n.New(cfg, docs...)
|
||||
}
|
||||
|
||||
func application(b *i18n.Bundle) (*omni.App, error) {
|
||||
policy, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localizer := errori18n.New(b, nil)
|
||||
if err := localizer.Validate(catalog()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := omni.New(omni.WithErrors(catalog()), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999}), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
|
||||
a.GET("/", func(c *httpx.Context) error {
|
||||
data, err := resources.ReadFile("index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Bytes(200, "text/html; charset=utf-8", data)
|
||||
})
|
||||
a.GET("/runtime.mjs", func(c *httpx.Context) error {
|
||||
data, err := fs.ReadFile(browseri18n.Files(), "runtime.mjs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Bytes(200, "text/javascript; charset=utf-8", data)
|
||||
})
|
||||
a.GET("/locales/:file", func(c *httpx.Context) error {
|
||||
var locale string
|
||||
switch c.Param("file") {
|
||||
case "en.json":
|
||||
locale = "en"
|
||||
case "zh-Hans.json":
|
||||
locale = "zh-Hans"
|
||||
default:
|
||||
return apierrors.NotFound("NOT_FOUND", "language pack not found")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := b.Export(&out, locale, "ui"); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Bytes(200, "application/json; charset=utf-8", out.Bytes())
|
||||
})
|
||||
a.GET("/api/limit", func(*httpx.Context) error {
|
||||
return apierrors.ByCode("LIMIT_EXCEEDED").WithParams(map[string]any{"max": int64(100)})
|
||||
})
|
||||
a.POST("/api/action", func(c *httpx.Context) error { return c.OK(map[string]bool{"accepted": true}) })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
serve := flag.String("serve", "", "optional local HTTP listen address")
|
||||
locale := flag.String("export-locale", "", "export a UI language pack to stdout")
|
||||
exportCatalog := flag.Bool("catalog", false, "export the Go error catalog to stdout")
|
||||
flag.Parse()
|
||||
if err := run(*serve, *locale, *exportCatalog); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run(serve, locale string, exportCatalog bool) error {
|
||||
if exportCatalog {
|
||||
return apierrors.ExportCatalog(os.Stdout, catalog())
|
||||
}
|
||||
b, err := bundle()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locale != "" {
|
||||
return b.Export(os.Stdout, locale, "ui")
|
||||
}
|
||||
if serve != "" {
|
||||
a, err := application(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.Close()
|
||||
return a.Run(serve)
|
||||
}
|
||||
for _, l := range b.Config().Locales {
|
||||
result, err := b.Render(l, i18n.Ref{Namespace: "ui", Key: "greeting"}, map[string]any{"name": "Alex"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(result.Locale + ": " + result.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
34
examples/i18n/main_test.go
Normal file
34
examples/i18n/main_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndependentExampleAndHTTP(t *testing.T) {
|
||||
b, err := bundle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if missing := b.Check(); len(missing) != 0 {
|
||||
t.Fatal(missing)
|
||||
}
|
||||
a, err := application(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
for _, test := range []struct {
|
||||
path, part string
|
||||
status int
|
||||
}{{"/api/limit", "数量不能超过 100", 409}, {"/locales/zh-Hans.json", `"namespace": "ui"`, 200}, {"/runtime.mjs", "createLanguageController", 200}, {"/", "draft", 200}} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", test.path, nil)
|
||||
r.Header.Set("Accept-Language", "zh-CN")
|
||||
a.ServeHTTP(w, r)
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.part) {
|
||||
t.Fatal(test.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
|
||||
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
||||
"main.go": "10cb6a84b2eb1b68dc10c2dca949c9dd7c68a9f78a8e6d8f56ce9336d4b2d125"
|
||||
"main.go": "e1cdd80f8ee090f1391cdfa799762c99b10d4e09715e9d4faefb2cb0291f1c05"
|
||||
},
|
||||
"contract": {
|
||||
"routes": {
|
||||
|
||||
@@ -2,7 +2,7 @@ module example.com/managed
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.27.0-preview
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
|
||||
@@ -41,7 +41,7 @@ func run(ctx context.Context, path string) (runErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Unjoined background work may still log. The owning process exits on this
|
||||
// Unjoined HTTP or background work may still log. The owning process exits on this
|
||||
// error; do not close its borrowed logger before that work has finished.
|
||||
defer func() {
|
||||
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
|
||||
@@ -52,8 +52,8 @@ func run(ctx context.Context, path string) (runErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||
// and closes them exactly once, before closing the borrowed logger.
|
||||
// Finish propagates incomplete HTTP draining: stop background work but retain
|
||||
// shared dependencies when any handler is still running. Otherwise close once.
|
||||
defer func() { runErr = deps.Finish(runErr) }()
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
|
||||
6
examples/program/.gitignore
vendored
Normal file
6
examples/program/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/program
|
||||
/var/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.goOmni/project.lock
|
||||
11
examples/program/.goOmni/projectManifest.json
Normal file
11
examples/program/.goOmni/projectManifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "project",
|
||||
"templateVersion": "v0.28.0-preview",
|
||||
"module": "example.com/program",
|
||||
"generated": {
|
||||
"internal/config/loadGen.go": "8a1a2746f46596a7893832ca411bd7e2d23978661943d322ee732d2c095cbe47",
|
||||
"internal/svc/runtimeGen.go": "0eb681169112302add633cc7355fbd19844b01fa0b38b94625ec18127e5fb937",
|
||||
"main.go": "36168d3e3fe822f74cede8eac3fffe5502174899e242c41526f6ad5cab7af4fd"
|
||||
}
|
||||
}
|
||||
18
examples/program/README.md
Normal file
18
examples/program/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 通用程序示例
|
||||
|
||||
由 `go-omni project new` 生成,入口、加载和装配文件未经修改。项目仅在配置添加 `once`,在 `internal/run/run.go` 决定完成即退出或等待信号;无端口、数据库、队列和业务任务。
|
||||
|
||||
```sh
|
||||
cd examples/program
|
||||
go mod tidy
|
||||
go run . -check-config
|
||||
go run . # once: true,完成即退出
|
||||
# 编辑 etc/config.yaml 为 once: false 后运行,用 Ctrl+C / SIGTERM 停止。
|
||||
go build -o program .
|
||||
go-omni project check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
使用同版远程 v0.28.0-preview CLI 与框架,无本地 replace。源码发布前开发可临时 `go mod edit -replace git.yuntuo.hk/k2safe/go-omni=../..`,验证后移除此本地替换。
|
||||
|
||||
配置、svc、Run 归项目维护;main/Gen.go 和清单由生成器维护。背景组件在 Init 登记停止与排空,已接受工作使用 WorkContext;未结束时不会提前释放共享依赖。详细规则见 [PROGRAM.md](../../PROGRAM.md)。
|
||||
12
examples/program/etc/config.yaml
Normal file
12
examples/program/etc/config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}.
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
output: stderr
|
||||
lifecycle:
|
||||
stopTimeout: 5s
|
||||
drainTimeout: 10s
|
||||
cancelTimeout: 5s
|
||||
|
||||
# true: finite run; false: wait for SIGINT/SIGTERM.
|
||||
once: true
|
||||
5
examples/program/go.mod
Normal file
5
examples/program/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module example.com/program
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.28.0-preview
|
||||
29
examples/program/internal/config/config.go
Normal file
29
examples/program/internal/config/config.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
// Config, Default and Validate are project-owned. Add project settings here.
|
||||
type Config struct {
|
||||
Once bool `json:"once"`
|
||||
Log logx.Config `json:"log"`
|
||||
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
log := logx.Default()
|
||||
log.Service = "example.com/program"
|
||||
return Config{Once: true, Log: log, Lifecycle: lifecycle.DefaultShutdownConfig()}
|
||||
}
|
||||
func (c Config) Validate() error {
|
||||
if err := c.Log.Validate(); err != nil {
|
||||
return omniConfig.AtField("log", err)
|
||||
}
|
||||
if err := c.Lifecycle.Validate(); err != nil {
|
||||
return omniConfig.AtField("lifecycle", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
12
examples/program/internal/config/loadGen.go
Normal file
12
examples/program/internal/config/loadGen.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Code generated by go-omni project. DO NOT EDIT.
|
||||
package config
|
||||
|
||||
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
loader, err := NewLoader()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return omniConfig.LoadProject(path, Default(), loader)
|
||||
}
|
||||
6
examples/program/internal/config/loader.go
Normal file
6
examples/program/internal/config/loader.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
|
||||
// NewLoader is project-owned. Register additional decoders here if needed.
|
||||
func NewLoader() (*omniConfig.Loader, error) { return omniConfig.NewLoader() }
|
||||
19
examples/program/internal/run/run.go
Normal file
19
examples/program/internal/run/run.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/program/internal/svc"
|
||||
)
|
||||
|
||||
// Run is project-owned. Return nil for normal completion, or an error to fail.
|
||||
// The starter waits for shutdown; a one-shot program can simply return when done.
|
||||
// Register background stop/drain hooks in svc.Init, not concurrently here.
|
||||
// Accepted background work uses deps.WorkContext(), not the early-stop ctx.
|
||||
func Run(ctx context.Context, deps *svc.ServiceContext) error {
|
||||
if deps.Config.Once {
|
||||
deps.Logger.InfoContext(ctx, "program.completed")
|
||||
return nil
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
44
examples/program/internal/svc/runtimeGen.go
Normal file
44
examples/program/internal/svc/runtimeGen.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Code generated by go-omni project. DO NOT EDIT.
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/program/internal/config"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func New(ctx context.Context, logger *logx.Logger, cfg config.Config) (result *ServiceContext, err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return nil, errors.Join(err, lifecycle.Cause(ctx))
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("service context requires a logger")
|
||||
}
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtime, err := lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx, logger), cfg.Lifecycle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &ServiceContext{Runtime: runtime, Config: cfg, Logger: logger}
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
err = fmt.Errorf("dependency initialization panic: %v", p)
|
||||
}
|
||||
if err != nil {
|
||||
result = nil
|
||||
err = c.Finish(err)
|
||||
}
|
||||
}()
|
||||
if err = c.Init(c.Context()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
24
examples/program/internal/svc/serviceContext.go
Normal file
24
examples/program/internal/svc/serviceContext.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/program/internal/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
// ServiceContext is project-owned. Add dependencies without global variables.
|
||||
// Runtime owns registered resources; Logger is borrowed from the entry point.
|
||||
type ServiceContext struct {
|
||||
*lifecycle.Runtime
|
||||
Config config.Config
|
||||
Logger *logx.Logger
|
||||
}
|
||||
|
||||
// Init runs before Run. Register resources immediately with OnClose.
|
||||
// Register paired OnStop/OnDrain hooks before exposing background work.
|
||||
// Use WorkContext for accepted work; Context is the early stop notification.
|
||||
// OnDrain returns nil only after processing and final cleanup have completed.
|
||||
// Complete all registrations here, before Run can race with shutdown.
|
||||
// The drain name "go-omni/project/run" is reserved for the generated entry.
|
||||
func (c *ServiceContext) Init(ctx context.Context) error { return nil }
|
||||
89
examples/program/main.go
Normal file
89
examples/program/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Code generated by go-omni project. DO NOT EDIT.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/program/internal/config"
|
||||
program "example.com/program/internal/run"
|
||||
"example.com/program/internal/svc"
|
||||
"flag"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := flag.String("config", "etc/config.yaml", "configuration file; empty uses Default()")
|
||||
check := flag.Bool("check-config", false, "validate configuration without initializing 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)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, path string) (runErr error) {
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger, err := logx.New(cfg.Log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var deps *svc.ServiceContext
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
runErr = fmt.Errorf("program initialization panic: %v", p)
|
||||
}
|
||||
if deps != nil {
|
||||
runErr = deps.Finish(runErr)
|
||||
}
|
||||
// Unjoined work may still use its dependencies and logger. The process exits
|
||||
// unsuccessfully; resource retention never means the work completed.
|
||||
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
|
||||
runErr = errors.Join(runErr, logger.Close())
|
||||
}
|
||||
}()
|
||||
deps, err = svc.New(ctx, logger, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deps.Context().Err() != nil {
|
||||
return nil
|
||||
}
|
||||
done := make(chan struct{})
|
||||
// This reserved drain proves Run has actually returned, including its defers.
|
||||
// Runtime bounds the wait even when Run ignores cancellation.
|
||||
deps.OnDrain("go-omni/project/run", func(context.Context) error { <-done; return nil })
|
||||
logger.InfoContext(deps.Context(), "program.starting")
|
||||
go func() {
|
||||
var result error
|
||||
returned := false
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
result = fmt.Errorf("program run panic: %v", p)
|
||||
} else if !returned {
|
||||
result = errors.New("program run exited without returning")
|
||||
}
|
||||
deps.RequestShutdown(result)
|
||||
close(done)
|
||||
}()
|
||||
result = program.Run(deps.Context(), deps)
|
||||
returned = true
|
||||
}()
|
||||
<-deps.Context().Done()
|
||||
return nil
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module example.com/userService
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
|
||||
git.yuntuo.hk/k2safe/go-omni v0.27.0-preview
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
)
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@@ -17,6 +17,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.31.0
|
||||
golang.org/x/mod v0.21.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/text v0.21.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
@@ -48,6 +49,5 @@ require (
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"git.yuntuo.hk/k2safe/go-omni/validate"
|
||||
@@ -88,15 +89,16 @@ func RoutePattern(ctx context.Context) string {
|
||||
// only standard-library HTTP types, so it remains usable when the network
|
||||
// implementation is replaced.
|
||||
type Context struct {
|
||||
request *http.Request
|
||||
writer http.ResponseWriter
|
||||
params map[string]string
|
||||
values map[string]any
|
||||
responded bool
|
||||
status int
|
||||
encoder response.Encoder
|
||||
errorRegistry *apierrors.Registry
|
||||
failure *apierrors.APIError
|
||||
request *http.Request
|
||||
writer http.ResponseWriter
|
||||
params map[string]string
|
||||
values map[string]any
|
||||
responded bool
|
||||
status int
|
||||
encoder response.Encoder
|
||||
errorRegistry *apierrors.Registry
|
||||
errorLocalizer apierrors.Localizer
|
||||
failure *apierrors.APIError
|
||||
}
|
||||
|
||||
// NewContext creates a transport-neutral GoOmni context.
|
||||
@@ -136,6 +138,7 @@ func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Contex
|
||||
clone := NewContext(c.request.Clone(ctx), writer, c.params)
|
||||
clone.encoder = c.encoder
|
||||
clone.errorRegistry = c.errorRegistry
|
||||
clone.errorLocalizer = c.errorLocalizer
|
||||
for key, value := range c.values {
|
||||
clone.values[key] = value
|
||||
}
|
||||
@@ -282,7 +285,12 @@ func (c *Context) WriteError(err error) {
|
||||
if errors.As(err, &bodyErr) {
|
||||
err = bodyTooLarge()
|
||||
}
|
||||
var original *apierrors.APIError
|
||||
tooLarge := errors.As(err, &original) && original.Status == http.StatusRequestEntityTooLarge
|
||||
apiErr := c.ResolveError(err)
|
||||
if tooLarge || apiErr.Status == http.StatusRequestEntityTooLarge {
|
||||
httplimits.RejectBody(c.Raw())
|
||||
}
|
||||
c.failure = apiErr
|
||||
if writeErr := c.writeResult(response.Result{
|
||||
BusinessCode: apiErr.BusinessCode,
|
||||
@@ -301,16 +309,20 @@ func (c *Context) WriteError(err error) {
|
||||
// SetErrorRegistry sets an application fallback. Route encoders can supply their
|
||||
// own nonempty registry; an encoder without a registry inherits this fallback.
|
||||
// Clones retain both policies without any process-global lookup.
|
||||
func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r }
|
||||
func (c *Context) ResolveError(err error) *apierrors.APIError {
|
||||
func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r }
|
||||
func (c *Context) SetErrorLocalizer(l apierrors.Localizer) { c.errorLocalizer = l }
|
||||
|
||||
// ErrorRegistry returns the effective route or application error scope.
|
||||
func (c *Context) ErrorRegistry() *apierrors.Registry {
|
||||
registry := c.errorRegistry
|
||||
if provider, ok := c.encoder.(response.ErrorRegistryProvider); ok {
|
||||
if scoped := provider.ErrorRegistry(); scoped != nil {
|
||||
registry = scoped
|
||||
}
|
||||
}
|
||||
return registry.Resolve(err)
|
||||
return registry
|
||||
}
|
||||
func (c *Context) ResolveError(err error) *apierrors.APIError { return c.ErrorRegistry().Resolve(err) }
|
||||
|
||||
// RecordError preserves failure semantics when a middleware commits buffered bytes.
|
||||
func (c *Context) RecordError(err error) { c.failure = c.ResolveError(err) }
|
||||
|
||||
@@ -53,6 +53,15 @@ func (c *Context) writeResult(result response.Result) (err error) {
|
||||
if encoder == nil {
|
||||
encoder = response.Default()
|
||||
}
|
||||
if c.errorLocalizer != nil && result.Err != nil {
|
||||
if message, ok := c.localizedMessage(result.Err); ok {
|
||||
result.MessageOverride = &message
|
||||
if c.failure != nil {
|
||||
c.failure = c.failure.Clone()
|
||||
c.failure.Message = message
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, err := encoder.Encode(result)
|
||||
if err != nil {
|
||||
return apierrors.Internal(err)
|
||||
@@ -69,6 +78,17 @@ func (c *Context) writeResult(result response.Result) (err error) {
|
||||
return c.WriteResponse(payload.Status, payload.Header, payload.Body)
|
||||
}
|
||||
|
||||
// Presentation adapter failures cannot turn a business result into a new error.
|
||||
func (c *Context) localizedMessage(err error) (message string, ok bool) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
message, ok = "", false
|
||||
}
|
||||
}()
|
||||
registry := c.ErrorRegistry()
|
||||
return c.errorLocalizer.Localize(c.Context(), registry, registry.Resolve(err))
|
||||
}
|
||||
|
||||
// RespondStatus uses the current response format and error catalog, but keeps
|
||||
// the explicit HTTP status even if a catalog/custom encoder selects another.
|
||||
// This is useful for protocol endpoints such as health probes. Ordinary
|
||||
|
||||
180
i18n/README.md
Normal file
180
i18n/README.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 通用国际化
|
||||
|
||||
本功能从 `v0.26.0-preview` 提供。`i18n` 可以单独用于 CLI、后台任务、通知或 Web 服务,不依赖 App、HTTP、错误目录、数据库或前端框架。业务文案、启用语言和用户偏好由接入项目维护。
|
||||
|
||||
模块没有全局当前语言、全局可变词典或自动文件扫描。词典构建后只读,支持多个实例、多个命名空间和并发调用。完整可运行示例见 [examples/i18n](../examples/i18n/README.md),生成契约见 [i18n.api](../examples/contracts/i18n.api)。
|
||||
|
||||
## 独立使用
|
||||
|
||||
```go
|
||||
bundle, err := i18n.New(i18n.Config{
|
||||
SourceLocale: "en", DefaultLocale: "en",
|
||||
Locales: []string{"en", "zh-Hans"},
|
||||
},
|
||||
i18n.Document{Version: 1, Namespace: "common", Locale: "en",
|
||||
Messages: map[string]i18n.Message{
|
||||
"greeting": {
|
||||
Params: map[string]i18n.Kind{"name": i18n.String},
|
||||
Value: i18n.Pattern{Text: "Hello, {name}"},
|
||||
},
|
||||
}},
|
||||
i18n.Document{Version: 1, Namespace: "common", Locale: "zh-Hans",
|
||||
Messages: map[string]i18n.Message{
|
||||
"greeting": {Value: i18n.Pattern{Text: "你好,{name}"}},
|
||||
}},
|
||||
)
|
||||
if err != nil { return err }
|
||||
result, err := bundle.Render("zh-CN", i18n.Ref{
|
||||
Namespace: "common", Key: "greeting",
|
||||
}, map[string]any{"name": "Alex"})
|
||||
// result.Text == "你好,Alex"; result.Locale == "zh-Hans"
|
||||
```
|
||||
|
||||
`Render` 显式接受语言。也可以用 `WithLocale(ctx, locale)` 和 `RenderContext`,语言只属于该上下文。任务消费者需由项目显式取得接收者语言;Go context 不会自动跨进程或写入队列。
|
||||
|
||||
`Result` 提供文本、匹配后的请求语言、实际词典语言和是否发生回退。缺键、缺参、类型错误、渲染限额返回 `*Diagnostic`;核心不决定 HTTP 状态,也不把任意错误自动转换成业务错误。诊断没有参数值、内部原因或原始文案。
|
||||
|
||||
## 语言选择与回退
|
||||
|
||||
- `SourceLocale` 是定义消息和参数的语言,可以只用作最终回退而不对用户开放。
|
||||
- `DefaultLocale` 是无合适偏好时的选择,必须在 `Locales` 中。
|
||||
- `Locales` 是有序的启用语言。`Match` 使用 `x/text/language` 的 BCP 47 匹配,返回真实配置的词典标签;不会用匹配器生成的带扩展标签直接索引文件。
|
||||
- `Fallbacks` 是显式、有序、无环的关系。先查选中语言,再深度优先查配置的回退;然后查默认语言及其回退,最后查源语言,过程中去重。不会从文件名或地域字符串猜一条词典继承链。
|
||||
- 非法请求偏好忽略并使用可用偏好或默认语言;非法配置、重复规范化语言、回退循环在 `New` 时失败。
|
||||
|
||||
运行时允许部分语言缺词,`Check` 单独列出各启用语言缺少的直接翻译。CI 可因此失败,在线请求仍使用回退。存在但格式损坏的译文在构建时拒绝;替换词典应先构建新实例,成功后由项目替换实例。
|
||||
|
||||
## 文案资源 v1
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "notifications",
|
||||
"locale": "en",
|
||||
"messages": {
|
||||
"unread": {
|
||||
"params": {"count": "integer"},
|
||||
"value": {
|
||||
"plural": "count",
|
||||
"cases": {
|
||||
"=0": {"text": "No unread messages"},
|
||||
"one": {"text": "{count} unread message"},
|
||||
"other": {"text": "{count} unread messages"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同一命名空间的同一键只有一份源定义。译文省略 `params` 时继承源定义;重复声明时必须完全一致。参数是消息级契约:各语言使用相同参数集合,分支可以不使用其中某个参数,但整条消息的所有分支合起来必须使用全部参数。
|
||||
|
||||
`value` 支持:
|
||||
|
||||
- `text`:命名插值 `{name}`,`{{`、`}}` 表示字面花括号;空字符串是合法译文。
|
||||
- `literal: true`:文本原样保留,适用于已有固定消息。插入参数不会再次解析,也不会自动查词典。
|
||||
- `select`:根据 string/boolean 参数选择命名分支,必须有 `other`。
|
||||
- `plural`:根据 integer/number 参数选择基数复数分支,必须有 `other`;先匹配 `=0` 等精确 int64 分支,再匹配 `zero/one/two/few/many/other`。可与 select 嵌套。
|
||||
|
||||
复数规则固定为 [Unicode CLDR JSON 46.1.0](https://github.com/unicode-org/cldr-json/blob/46.1.0/cldr-json/cldr-core/supplemental/plurals.json)。Go 和浏览器使用同一份规则,导出包携带实际语言及回退语言的规则,避免浏览器自带规则版本不同。数据来源和许可见 [cldr](cldr/README.md)。规则版本与文案格式版本分别记录。
|
||||
|
||||
参数类型:
|
||||
|
||||
| 类型 | Go 调用 | 浏览器调用 |
|
||||
| --- | --- | --- |
|
||||
| `string` | 字符串 | string |
|
||||
| `boolean` | bool | boolean |
|
||||
| `integer` | int/int32/int64 等整数,或精确的 json.Number;范围为 int64 | 安全整数 number、bigint 或十进制整数字符串;超范围拒绝 |
|
||||
| `number` | 有限 float32/float64 或 json.Number | 有限 number |
|
||||
|
||||
Number 按规范化数值处理:`1.0` 与 `1` 等价,负零显示为 `0`,不保留输入小数位数。整数不会经过 float64;不接受任意对象、Stringer、nil、自动字符串转数值。日期、货币、精度敏感的小数和字段标签可由项目显式格式化为字符串参数,时区与币种不会从语言推断。
|
||||
|
||||
v1 的范围是普通文本、命名参数、select 和基数复数,不宣称兼容完整 ICU MessageFormat;不含 ordinal、紧凑数字格式、HTML 执行或任意表达式。复数计算使用普通数值,CLDR 的 compact-number `c/e` 操作数为 0。字段文案可以先显式 Render 后再作为参数;用户输入和业务数据保持原样。
|
||||
|
||||
## 加载、覆盖与资源限制
|
||||
|
||||
`LoadFS(fs.FS, paths...)` 读取明确列出的 JSON 文件,支持 `embed.FS` 和 `os.DirFS`。`DecodeJSON` 可用于业务自己取得的内存数据,拒绝重复键、未知字段、无效 UTF-8、超深结构。它们不展开 `${...}`,不执行项目代码,不自动拉远程词典。文件系统实际访问范围由传入的 FS 决定。
|
||||
|
||||
项目已有配置加载器可加载 `i18n.Config` 的语言设置,词典正文用本包加载;避免配置加载器把词典里的 `${...}` 解释成环境变量。数据库或远程词典由项目读取为 Document,再调用 New。
|
||||
|
||||
同一语言、命名空间可拆成多个文件;重复键默认报错。`Overlay(base, override)` 明确替换完整消息并返回独立副本,再传给 New 校验;不隐式合并分支或参数。项目决定覆盖的先后顺序。
|
||||
|
||||
主要限额:每个 JSON 文件 1 MiB、LoadFS 总量 4 MiB、最多 1024 个文档、64 个资源语言、128 个命名空间、10000 条消息、每条 64 个参数、单文本/字符串参数 64 KiB、模式深度 24、渲染结果 1 MiB。导出的单语言包最多 4 MiB,可按命名空间拆分。
|
||||
|
||||
`Config()`、`Documents()` 和 `Schema()` 返回独立副本;`Config.Validate()` 可预检语言配置。运行时没有修改词典的方法。
|
||||
|
||||
## 接入 go-omni 错误与 HTTP
|
||||
|
||||
```go
|
||||
// 项目自己的 Catalog,既有 status/code/message/params 继续维护在这里。
|
||||
catalog = errori18n.Bind(catalog, "accountErrors")
|
||||
sourceDocuments, err := errori18n.Source(catalog, "en")
|
||||
// 把 sourceDocuments 与项目加载的译文一起传入 i18n.New。
|
||||
// Bind 不修改输入;已有显式 translation 引用保持。
|
||||
|
||||
languagePolicy, err := httpi18n.New(bundle)
|
||||
localizer := errori18n.New(bundle, nil) // 第二参数可提供并发安全的诊断回调。
|
||||
if err := localizer.Validate(catalog); err != nil { return err }
|
||||
|
||||
app := omni.New(
|
||||
omni.WithErrors(catalog),
|
||||
omni.WithLanguagePolicy(languagePolicy),
|
||||
omni.WithErrorLocalizer(localizer),
|
||||
// 继续传入项目自己的 WithResponse / WithResponseEncoder。
|
||||
)
|
||||
```
|
||||
|
||||
也可在单项 Definition 或 `.api errorCatalog` 条目明确声明:
|
||||
|
||||
```json
|
||||
"translation": {"namespace":"accountErrors", "key":"username.invalid"}
|
||||
```
|
||||
|
||||
翻译引用与逻辑错误标识、对外业务码分别独立。不同目录可以为同名错误配置不同引用;多个错误引用同一源消息时,Source 会拒绝不一致的源文案或参数。Go 源目录通过 Source 导出,保留原固定文案的字面花括号,不包含状态、业务码、example 或 cause。
|
||||
|
||||
HTTP 策略在应用中间件之前选语言,默认读取有界 `Accept-Language`,保留请求上下文中显式设置的语言优先级。`httpi18n.WithResolver` 可替换语言来源,并显式声明缓存 Vary 所需的头;无头可声明的个性化来源自动标记 private。HTTP 策略不会读取用户数据库、JWT 或 Cookie 中的业务字段,也不自动设置可能与逐词回退不符的 Content-Language。独立标准 HTTP 可以用 `Policy.Wrap`。
|
||||
|
||||
自定义 Resolver panic 时按无偏好处理,继续使用显式上下文语言或默认语言,避免偏好查询故障中断业务请求。项目可在自己的 Resolver 中记录诊断。
|
||||
|
||||
错误适配器先使用原有规则解析业务错误,只替换展示文案;缺资源、适配失败保持原文,参数调用错误仍遵循原有 INTERNAL_ERROR 处理。没有 translation 引用的条目保持原行为。标准响应的平铺字段、嵌套 `${message}`、RawJSON 错误、旧 Messages 校验、路由独立目录、认证提前返回和缓冲克隆均保留原语义。
|
||||
|
||||
`response.Result.Message` 继续是契约原文;新增 `MessageOverride` 和 `PublicMessage()`。内置 Formatter 使用最终公开文案。自定义 Encoder 需要翻译时显式使用 `PublicMessage()`;固定回执继续由 Encoder 决定。`c.JSON/Text/XML/Bytes` 等直接输出不自动重写。
|
||||
|
||||
失败快照保存当次展示文案。幂等重放保留首次响应和失败元数据,不重新翻译,不把语言加入业务幂等键。`Localizer.Localize(ctx, registry, resolvedError)` 也可供非 HTTP 入口使用;返回值只包含文本和是否应用,不修改传入错误。
|
||||
|
||||
生成器识别 translation 元数据并写入 Catalog、路由目录和 OpenAPI;已有无 translation 的契约不变。新 metadata 的远程 runtime 下限为 `v0.26.0-preview`,CLI 和框架使用同版;开发框架时可以显式使用本地 checkout。生成到不具备能力的旧依赖会在写入前失败。
|
||||
|
||||
## CLI 提取、检查与导出
|
||||
|
||||
下面的命令在框架根目录可以直接运行;安装后的 CLI 把 `go run ./cmd/go-omni` 换成 `go-omni`。
|
||||
|
||||
```sh
|
||||
go run ./cmd/go-omni i18n check --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json
|
||||
|
||||
go run ./cmd/go-omni i18n export --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json \
|
||||
--locale zh-Hans --namespace ui
|
||||
|
||||
go run ./cmd/go-omni i18n types --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json --namespace ui
|
||||
|
||||
go run ./cmd/go-omni i18n extract --go examples/i18n/main.go
|
||||
```
|
||||
|
||||
- `check` 检查资源和直接翻译遗漏,非空诊断退出非零;可重复提供 `--go` 检查静态引用。
|
||||
- `extract --go` 识别 Go 中 `i18n.Ref` 的字符串字面量及 import 别名;动态引用、非字面量 Render 调用和 dot import 会报告,不能声称完成任意 Go/JS/模板的静态分析。提取命令输出发现结果,check 把未解决动态引用视为诊断。
|
||||
- `extract --catalog exported-errors.json --namespace errors --locale en` 读取已有错误目录导出物,生成单个源语言 Document,不执行项目 Go。目录可以由已有 workspace Go Catalog 导出流程产生。
|
||||
- `export` 显式选择命名空间,输出一个选中语言及全部回退资源的 Pack,不输出业务错误元数据。可重复 `--namespace`。
|
||||
- `types` 输出按 namespace/key 分层的 TypeScript 参数类型及 Translate 类型,不强制 Go 命名风格,不把 int64 简单降成 number。
|
||||
|
||||
这些命令只读输入并输出 stdout,没有自动覆盖业务文件、扫描其他项目、翻译服务调用或安装前端依赖。`Bundle.Export`、`TypeScript` 提供相同的库入口。
|
||||
|
||||
## 前端
|
||||
|
||||
见 [browser](browser/README.md)。运行时是普通 ES module,带 TypeScript 声明,可按项目需要复制进前端或通过 `browseri18n.Files()` 提供静态文件。语言包按需加载、先验证再提交;连续切换只有最新选择能生效,失败保留已提交语言。没有 DOM、localStorage、表单或业务请求的隐式操作。
|
||||
|
||||
升级本模块时必须一起验证资源格式、规则版本、前后端一致性、错误兼容和生成消费者。现有项目不启用这两个适配选项时,响应与错误行为保持原样。
|
||||
39
i18n/browser/README.md
Normal file
39
i18n/browser/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 浏览器运行时
|
||||
|
||||
`runtime.mjs` 是可独立导入的 ES module,`runtime.d.mts` 提供类型。需要支持 ES modules、BigInt、TextEncoder 和 structuredClone 的现代浏览器;Node.js 22 用于本仓库验证。复数规则来自导出包,与 Go 共用固定 CLDR 数据,不调用浏览器自己的 Intl.PluralRules。语言选择使用包中 `locales` 的精确标识,不由浏览器再次改写别名或推断回退链。
|
||||
|
||||
```js
|
||||
import { createLanguageController, fetchPack } from './runtime.mjs';
|
||||
|
||||
const urls = { en: '/locales/en.json', 'zh-Hans': '/locales/zh-Hans.json' };
|
||||
const language = createLanguageController(
|
||||
await fetchPack(urls.en),
|
||||
locale => fetchPack(urls[locale])
|
||||
);
|
||||
|
||||
await language.setLanguage('zh-Hans');
|
||||
const text = language.current.t('ui', 'greeting', { name: 'Alex' });
|
||||
```
|
||||
|
||||
`current.render` 额外返回实际语言和 fallback 标记。`setLanguage` 成功提交返回 true;被更新选择取代返回 false;最新加载失败抛错并保留 current。甚至“切回当前语言”也会取消旧请求的提交资格。未知语言、错误语言包、参数 schema 或启用语言集合变化都不能提交。
|
||||
|
||||
`fetchPack` 检查 HTTP 状态和 4 MiB 限额,支持 AbortSignal 和自定义 fetch。加载资源、鉴权、缓存策略与 URL 映射由调用者决定。它只返回 JSON 数据,`createLocalizer` / controller 负责完整资源检查。
|
||||
|
||||
`overlayPack(pack, documents)` 显式覆盖已导出键的完整消息并重新校验,不修改输入,不允许改变参数契约。例如替换品牌标题;覆写源消息时保留原 params。业务可在 loader 返回之前应用 overrides。
|
||||
|
||||
```ts
|
||||
import type { Translate } from './messages.generated';
|
||||
const t: Translate = language.current.t;
|
||||
// t('ui', 'greeting', { name: 'Alex' })
|
||||
```
|
||||
|
||||
应用需要在切换成功后重新取得 current.t。视图使用 textContent 或框架的文本绑定,动态参数不递归解析、不作为 HTML 执行。金额格式化、RTL 布局、表单、路由、业务提交和偏好持久化属于应用;运行时不重建页面或重新发业务请求。
|
||||
|
||||
完整表单示例:[examples/i18n](../../examples/i18n/README.md)。验证:
|
||||
|
||||
```sh
|
||||
node --test i18n/browser/runtime.test.mjs
|
||||
go test ./i18n -run TestBrowserConformance
|
||||
# PATH 中提供 tsc 时,验证导出类型与实际 runtime 声明及错误参数诊断。
|
||||
go test ./i18n -run TestTypeScriptConsumer -v
|
||||
```
|
||||
13
i18n/browser/assets.go
Normal file
13
i18n/browser/assets.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Package browseri18n embeds the optional framework-neutral browser module.
|
||||
// Frontend projects can also copy/import runtime.mjs directly.
|
||||
package browseri18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed runtime.mjs runtime.d.mts
|
||||
var files embed.FS
|
||||
|
||||
func Files() fs.FS { return files }
|
||||
6
i18n/browser/conformance.mjs
Normal file
6
i18n/browser/conformance.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLocalizer } from "./runtime.mjs";
|
||||
const { pack, cases } = JSON.parse(readFileSync(0, "utf8"));
|
||||
const localizer = createLocalizer(pack);
|
||||
for (const sample of cases) assert.equal(localizer.t("common", sample.key, sample.params), sample.want);
|
||||
29
i18n/browser/runtime.d.mts
Normal file
29
i18n/browser/runtime.d.mts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Kind = "string" | "boolean" | "integer" | "number";
|
||||
export interface Pattern { text?: string; literal?: boolean; select?: string; plural?: string; cases?: Record<string, Pattern>; }
|
||||
export interface Message { params?: Record<string, Kind>; value: Pattern; }
|
||||
export interface Document { version: 1; namespace: string; locale: string; messages: Record<string, Message>; }
|
||||
export interface Configuration { sourceLocale: string; defaultLocale: string; locales: string[]; fallbacks?: Record<string, string[]>; }
|
||||
export interface Pack {
|
||||
version: 1;
|
||||
locale: string;
|
||||
config: Configuration;
|
||||
documents: Document[];
|
||||
ruleVersion: "cldr-46.1";
|
||||
pluralRules: Record<string, Partial<Record<"zero" | "one" | "two" | "few" | "many", string>>>;
|
||||
license?: string;
|
||||
}
|
||||
export interface Rendered { text: string; locale: string; requested: string; fallback: boolean; }
|
||||
export interface Localizer {
|
||||
readonly locale: string;
|
||||
readonly locales: readonly string[];
|
||||
render(namespace: string, key: string, params?: Record<string, unknown>): Rendered;
|
||||
t(namespace: string, key: string, params?: Record<string, unknown>): string;
|
||||
}
|
||||
export class MessageError extends Error { readonly kind: string; constructor(kind: string, problem: string); }
|
||||
export function createLocalizer(pack: Pack): Localizer;
|
||||
export function overlayPack(pack: Pack, overrides: Document[]): Pack;
|
||||
export function createLanguageController(initialPack: Pack, loadPack: (locale: string) => Promise<Pack>): {
|
||||
readonly current: Localizer;
|
||||
setLanguage(locale: string): Promise<boolean>;
|
||||
};
|
||||
export function fetchPack(url: string | URL, options?: { signal?: AbortSignal; fetch?: typeof globalThis.fetch }): Promise<Pack>;
|
||||
305
i18n/browser/runtime.mjs
Normal file
305
i18n/browser/runtime.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
// Framework-neutral runtime for go-omni message resource version 1.
|
||||
// No globals, DOM changes, persistence, HTML insertion, or business requests.
|
||||
const owns = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
||||
const kinds = new Set(["string", "boolean", "integer", "number"]);
|
||||
const pluralCases = new Set(["zero", "one", "two", "few", "many", "other"]);
|
||||
const integer = /^-?(0|[1-9][0-9]*)$/;
|
||||
const paramName = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
||||
const encoder = new TextEncoder();
|
||||
const contractKey = Symbol("messageContract");
|
||||
const bytes = text => encoder.encode(text).length;
|
||||
const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const id = v => typeof v === "string" && v.length > 0 && bytes(v) <= 256 && v.trim() === v && !/[\u0000-\u001f\u007f-\u009f]/u.test(v);
|
||||
const fail = (kind, problem) => { throw new MessageError(kind, problem); };
|
||||
const fields = (value, allowed) => {
|
||||
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail("resource", "invalid resource fields");
|
||||
};
|
||||
const localeTag = value => {
|
||||
// Tags are canonicalized by the Go exporter. Keep their identity verbatim:
|
||||
// host Intl versions can remap aliases (e.g. cmn) or reject private-use tags.
|
||||
if (typeof value !== "string" || bytes(value) > 128 || value === "und" ||
|
||||
!/^(?:[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*|[xX](?:-[A-Za-z0-9]{1,8})+)$/.test(value)) fail("locale", "invalid exported locale identifier");
|
||||
return value;
|
||||
};
|
||||
|
||||
export class MessageError extends Error {
|
||||
constructor(kind, problem) { super(`i18n: ${problem}`); this.name = "MessageError"; this.kind = kind; }
|
||||
}
|
||||
|
||||
function scalar(kind, value) {
|
||||
if (kind === "string" && typeof value === "string" && bytes(value) <= 65536) return value;
|
||||
if (kind === "boolean" && typeof value === "boolean") return String(value);
|
||||
if (kind === "number" && typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (kind === "integer") {
|
||||
let n;
|
||||
if (typeof value === "bigint") n = value;
|
||||
else if (typeof value === "number" && Number.isSafeInteger(value)) n = BigInt(value);
|
||||
else if (typeof value === "string" && value.length <= 20 && integer.test(value)) n = BigInt(value);
|
||||
else fail("parameter", "expected an exact integer");
|
||||
if (n >= -(1n << 63n) && n < (1n << 63n)) return String(n);
|
||||
}
|
||||
fail("parameter", "wrong argument type, range or size");
|
||||
}
|
||||
|
||||
function compile(pattern, schema, used, budget, depth = 0) {
|
||||
fields(pattern, ["text", "literal", "select", "plural", "cases"]);
|
||||
const text = pattern.text ?? "";
|
||||
if (typeof text !== "string" || (pattern.literal !== undefined && typeof pattern.literal !== "boolean")) fail("pattern", "invalid text or literal flag");
|
||||
budget.nodes++; budget.bytes += bytes(text);
|
||||
if (depth > 24 || budget.nodes > 100000 || budget.bytes > 4194304 || bytes(text) > 65536) fail("resource", "pattern exceeds limits");
|
||||
const select = pattern.select ?? "", plural = pattern.plural ?? "";
|
||||
if (typeof select !== "string" || typeof plural !== "string") fail("pattern", "invalid branch parameter");
|
||||
if (select || plural) {
|
||||
if ((select && plural) || text || pattern.literal || !object(pattern.cases)) fail("pattern", "invalid branch");
|
||||
const entries = Object.entries(pattern.cases), key = select || plural;
|
||||
if (!owns(pattern.cases, "other") || entries.length > 128) fail("pattern", "branch requires other and at most 128 cases");
|
||||
const kind = schema.get(key);
|
||||
if (plural ? kind !== "integer" && kind !== "number" : kind !== "string" && kind !== "boolean") fail("parameter", "branch parameter type mismatch");
|
||||
used.add(key);
|
||||
const cases = new Map();
|
||||
for (const [name, child] of entries) {
|
||||
if (!id(name)) fail("pattern", "invalid case name");
|
||||
if (plural && !pluralCases.has(name)) {
|
||||
if (!name.startsWith("=") || !integer.test(name.slice(1)) || scalar("integer", name.slice(1)) !== name.slice(1)) fail("pattern", "invalid plural case");
|
||||
}
|
||||
cases.set(name, compile(child, schema, used, budget, depth + 1));
|
||||
}
|
||||
return { select, plural, cases };
|
||||
}
|
||||
if (pattern.cases !== undefined) fail("pattern", "cases require a branch");
|
||||
if (pattern.literal) return { parts: [{ text }] };
|
||||
const parts = [];
|
||||
let literal = "";
|
||||
const flush = () => { if (literal) parts.push({ text: literal }); literal = ""; };
|
||||
for (let i = 0; i < text.length;) {
|
||||
const ch = text[i];
|
||||
if (ch !== "{" && ch !== "}") { literal += ch; i++; continue; }
|
||||
if (text[i + 1] === ch) { literal += ch; i += 2; continue; }
|
||||
if (ch === "}") fail("pattern", "unescaped closing brace");
|
||||
const end = text.indexOf("}", i + 1);
|
||||
if (end < 0) fail("pattern", "unclosed placeholder");
|
||||
const key = text.slice(i + 1, end);
|
||||
if (!schema.has(key)) fail("parameter", "undeclared placeholder");
|
||||
used.add(key); flush(); parts.push({ param: key }); i = end + 1;
|
||||
}
|
||||
flush(); return { parts };
|
||||
}
|
||||
|
||||
function pluralOperands(text) {
|
||||
const [mantissa, exponent = "0"] = text.replace(/^-/, "").split("e");
|
||||
let [whole, fraction = ""] = mantissa.split(".");
|
||||
const digits = whole + fraction, point = whole.length + Number(exponent);
|
||||
if (point <= 0) { whole = "0"; fraction = "0".repeat(-point) + digits; }
|
||||
else if (point >= digits.length) { whole = digits + "0".repeat(point - digits.length); fraction = ""; }
|
||||
else { whole = digits.slice(0, point); fraction = digits.slice(point); }
|
||||
const tail = fraction.replace(/0+$/, ""), i = BigInt(whole || "0"), f = BigInt(fraction || "0");
|
||||
return { n: i, i, v: BigInt(fraction.length), w: BigInt(tail.length), f, t: BigInt(tail || "0"), c: 0n, e: 0n, fractional: f !== 0n };
|
||||
}
|
||||
|
||||
function compilePluralRules(rules) {
|
||||
if (!object(rules) || Object.keys(rules).some(k => !["zero", "one", "two", "few", "many"].includes(k))) fail("resource", "invalid plural rules");
|
||||
const compiled = new Map();
|
||||
for (const [category, text] of Object.entries(rules)) {
|
||||
if (typeof text !== "string" || text.length > 2048) fail("resource", "invalid plural rule size");
|
||||
compiled.set(category, text.split(" or ").map(or => or.split(" and ").map(and => {
|
||||
const match = /^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$/.exec(and.trim());
|
||||
if (!match) fail("resource", "invalid CLDR plural condition");
|
||||
const ranges = match[4].split(",").map(raw => { const [lo, hi = lo] = raw.trim().split(".."); const range = [BigInt(lo), BigInt(hi)]; if (range[0] > range[1]) fail("resource", "reversed plural range"); return range; });
|
||||
return { operand: match[1], mod: match[2] ? BigInt(match[2]) : undefined, negate: match[3] === "!=", ranges };
|
||||
})));
|
||||
}
|
||||
return text => {
|
||||
const values = pluralOperands(text);
|
||||
for (const category of ["zero", "one", "two", "few", "many"]) {
|
||||
const rule = compiled.get(category);
|
||||
if (rule?.some(group => group.every(condition => {
|
||||
let value = values[condition.operand]; if (condition.mod) value %= condition.mod;
|
||||
let found = (condition.operand !== "n" || !values.fractional) && condition.ranges.some(([lo, hi]) => value >= lo && value <= hi);
|
||||
return condition.negate ? !found : found;
|
||||
}))) return category;
|
||||
}
|
||||
return "other";
|
||||
};
|
||||
}
|
||||
|
||||
function render(pattern, locale, values, pluralRules) {
|
||||
if (pattern.cases) {
|
||||
let key = values.get(pattern.select);
|
||||
if (pattern.plural) {
|
||||
const value = values.get(pattern.plural);
|
||||
key = `=${value}`;
|
||||
if (!pattern.cases.has(key)) key = pluralRules.get(locale)(value);
|
||||
}
|
||||
return render(pattern.cases.get(key) ?? pattern.cases.get("other"), locale, values, pluralRules);
|
||||
}
|
||||
let text = "", size = 0;
|
||||
for (const part of pattern.parts) {
|
||||
const value = part.param === undefined ? part.text : values.get(part.param);
|
||||
size += bytes(value); if (size > 1048576) fail("resource", "rendered message exceeds 1 MiB");
|
||||
text += value;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Compiles a detached pack for one locale. Arguments are never recursively
|
||||
// interpreted as messages or HTML. Applications insert output as text.
|
||||
export function createLocalizer(pack) {
|
||||
fields(pack, ["version", "locale", "config", "documents", "ruleVersion", "pluralRules", "license"]);
|
||||
if (pack.version !== 1 || !Array.isArray(pack.documents) || pack.documents.length > 1024) fail("resource", "unsupported pack version or size");
|
||||
const c = pack.config;
|
||||
fields(c, ["sourceLocale", "defaultLocale", "locales", "fallbacks"]);
|
||||
const source = localeTag(c.sourceLocale), defaultLocale = localeTag(c.defaultLocale), locale = localeTag(pack.locale);
|
||||
if (!Array.isArray(c.locales) || c.locales.length < 1 || c.locales.length > 64) fail("locale", "invalid enabled locales");
|
||||
const locales = c.locales.map(localeTag), enabled = new Set(locales);
|
||||
if (enabled.size !== locales.length || ![defaultLocale, locale].every(l => enabled.has(l))) fail("locale", "duplicate or disabled locale");
|
||||
const fallback = new Map();
|
||||
if (c.fallbacks !== undefined && !object(c.fallbacks)) fail("locale", "invalid fallback configuration");
|
||||
for (const [raw, list] of Object.entries(c.fallbacks ?? {})) {
|
||||
const tag = localeTag(raw);
|
||||
if (!enabled.has(tag) || fallback.has(tag) || !Array.isArray(list) || list.length > 64) fail("locale", "invalid fallback configuration");
|
||||
const targets = list.map(localeTag);
|
||||
if (new Set(targets).size !== targets.length || targets.some(l => !enabled.has(l))) fail("locale", "invalid fallback targets");
|
||||
fallback.set(tag, targets);
|
||||
}
|
||||
const state = new Map();
|
||||
const visit = tag => { if (state.get(tag) === 1) fail("locale", "fallback cycle"); if (state.get(tag) === 2) return; state.set(tag, 1); for (const to of fallback.get(tag) ?? []) visit(to); state.set(tag, 2); };
|
||||
for (const l of locales) visit(l);
|
||||
const chain = [], seen = new Set();
|
||||
const add = l => { if (seen.has(l)) return; seen.add(l); chain.push(l); for (const to of fallback.get(l) ?? []) add(to); };
|
||||
add(locale); add(defaultLocale); add(source);
|
||||
if (pack.ruleVersion !== "cldr-46.1" || !object(pack.pluralRules) || Object.keys(pack.pluralRules).length > 65) fail("resource", "unsupported plural rule version");
|
||||
const pluralRules = new Map();
|
||||
for (const tag of chain) {
|
||||
if (!owns(pack.pluralRules, tag)) fail("resource", "missing plural rules for fallback language");
|
||||
pluralRules.set(tag, compilePluralRules(pack.pluralRules[tag]));
|
||||
}
|
||||
const namespaces = new Map(), documents = [], all = new Map();
|
||||
let count = 0;
|
||||
for (const doc of pack.documents) {
|
||||
fields(doc, ["version", "namespace", "locale", "messages"]);
|
||||
if (doc.version !== 1 || !id(doc.namespace) || !object(doc.messages)) fail("resource", "invalid document");
|
||||
const tag = localeTag(doc.locale);
|
||||
if (!enabled.has(tag) && tag !== source) fail("locale", "pack contains a disabled locale");
|
||||
if (!all.has(tag)) all.set(tag, new Map());
|
||||
const byNamespace = all.get(tag);
|
||||
if (!byNamespace.has(doc.namespace)) byNamespace.set(doc.namespace, new Set());
|
||||
for (const key of Object.keys(doc.messages)) {
|
||||
if (!id(key) || byNamespace.get(doc.namespace).has(key)) fail("resource", "invalid or duplicate message");
|
||||
byNamespace.get(doc.namespace).add(key);
|
||||
if (++count > 10000) fail("resource", "too many messages");
|
||||
}
|
||||
documents.push({ namespace: doc.namespace, tag, messages: doc.messages });
|
||||
}
|
||||
for (const doc of documents.filter(d => d.tag === source)) {
|
||||
if (!namespaces.has(doc.namespace)) namespaces.set(doc.namespace, new Map());
|
||||
const messages = namespaces.get(doc.namespace);
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
fields(message, ["params", "value"]);
|
||||
if (!object(message.value) || message.params !== undefined && !object(message.params)) fail("resource", "message requires value and valid params");
|
||||
const schema = new Map(Object.entries(message.params ?? {}));
|
||||
if (schema.size > 64 || [...schema].some(([k, v]) => !paramName.test(k) || bytes(k) > 128 || !kinds.has(v))) fail("parameter", "invalid parameter schema");
|
||||
messages.set(key, { schema, patterns: new Map() });
|
||||
}
|
||||
}
|
||||
if (namespaces.size > 128) fail("resource", "too many namespaces");
|
||||
const budget = { nodes: 0, bytes: 0 };
|
||||
for (const doc of documents) {
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
fields(message, ["params", "value"]);
|
||||
const entry = namespaces.get(doc.namespace)?.get(key);
|
||||
if (!entry) fail("missing", "translation has no source message");
|
||||
if (message.params !== undefined && (!object(message.params) || Object.keys(message.params).length !== entry.schema.size || Object.entries(message.params).some(([k, v]) => entry.schema.get(k) !== v))) fail("parameter", "translation schema differs from source");
|
||||
const used = new Set();
|
||||
entry.patterns.set(doc.tag, compile(message.value, entry.schema, used, budget));
|
||||
if ([...entry.schema.keys()].some(k => !used.has(k))) fail("parameter", "declared parameter is unused");
|
||||
}
|
||||
}
|
||||
const translate = (namespace, key, params = {}) => {
|
||||
const entry = namespaces.get(namespace)?.get(key);
|
||||
if (!entry) fail("missing", "unknown message");
|
||||
if (!object(params)) fail("parameter", "expected parameter object");
|
||||
const values = new Map();
|
||||
for (const [name, kind] of entry.schema) {
|
||||
if (!owns(params, name)) fail("parameter", "missing argument");
|
||||
values.set(name, scalar(kind, params[name]));
|
||||
}
|
||||
if (Object.keys(params).some(k => !entry.schema.has(k))) fail("parameter", "undeclared argument");
|
||||
for (const tag of chain) {
|
||||
const pattern = entry.patterns.get(tag);
|
||||
if (pattern) return { text: render(pattern, tag, values, pluralRules), locale: tag, requested: locale, fallback: tag !== locale };
|
||||
}
|
||||
fail("missing", "no message in fallback chain");
|
||||
};
|
||||
const contract = JSON.stringify([pack.ruleVersion, source, defaultLocale, locales, [...fallback].sort(), [...namespaces].sort().map(([ns, messages]) => [ns, [...messages].sort().map(([key, entry]) => [key, [...entry.schema].sort()])])]);
|
||||
return Object.freeze({ [contractKey]: contract, locale, locales: Object.freeze([...locales]), render: translate, t: (ns, key, params) => translate(ns, key, params).text });
|
||||
}
|
||||
|
||||
// Only a fully loaded and validated pack can replace the current language.
|
||||
// Last selection wins. A failed latest request keeps the committed language;
|
||||
// older requests cannot commit after it, even when they later succeed.
|
||||
export function createLanguageController(initialPack, loadPack) {
|
||||
if (typeof loadPack !== "function") fail("resource", "loader required");
|
||||
let current = createLocalizer(initialPack), sequence = 0;
|
||||
const enabled = [...current.locales];
|
||||
return Object.freeze({
|
||||
get current() { return current; },
|
||||
async setLanguage(raw) {
|
||||
const ticket = ++sequence, locale = localeTag(raw);
|
||||
if (!enabled.includes(locale)) fail("locale", "locale is not enabled");
|
||||
if (locale === current.locale) return true;
|
||||
let pack;
|
||||
try { pack = await loadPack(locale); }
|
||||
catch (error) { if (ticket !== sequence) return false; throw error; }
|
||||
if (ticket !== sequence) return false;
|
||||
const next = createLocalizer(pack);
|
||||
if (next.locale !== locale || next[contractKey] !== current[contractKey]) fail("locale", "loaded pack does not match requested language/configuration");
|
||||
current = next;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Explicit whole-message overrides. Originals stay untouched; unknown keys and
|
||||
// changed parameter schemas are rejected by validation before the pack is used.
|
||||
export function overlayPack(pack, overrides) {
|
||||
createLocalizer(pack);
|
||||
if (!Array.isArray(overrides) || overrides.length > 1024) fail("resource", "invalid overrides");
|
||||
const result = structuredClone(pack), seen = new Set();
|
||||
for (const doc of overrides) {
|
||||
fields(doc, ["version", "namespace", "locale", "messages"]);
|
||||
if (doc.version !== 1 || !object(doc.messages)) fail("resource", "invalid override document");
|
||||
const tag = localeTag(doc.locale);
|
||||
const target = result.documents.find(d => d.namespace === doc.namespace && localeTag(d.locale) === tag);
|
||||
if (!target) fail("resource", "override must target an exported namespace and locale");
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
const identity = JSON.stringify([tag, doc.namespace, key]);
|
||||
if (seen.has(identity) || !owns(target.messages, key)) fail("resource", "unknown or duplicate override key");
|
||||
seen.add(identity);
|
||||
Object.defineProperty(target.messages, key, { value: structuredClone(message), enumerable: true, writable: true, configurable: true });
|
||||
}
|
||||
}
|
||||
const checked = createLocalizer(result), original = createLocalizer(pack);
|
||||
if (checked[contractKey] !== original[contractKey]) fail("parameter", "overrides cannot change the message contract");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Optional bounded fetch loader. The caller supplies the URL mapping; locale is
|
||||
// never treated as an arbitrary path or endpoint by this runtime.
|
||||
export async function fetchPack(url, { signal, fetch: fetcher = globalThis.fetch } = {}) {
|
||||
const response = await fetcher(url, { signal, credentials: "same-origin" });
|
||||
if (!response.ok) fail("load", "language pack request failed");
|
||||
if (!response.body) fail("load", "language pack response has no body");
|
||||
const reader = response.body.getReader(), chunks = []; let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > 4194304) { await reader.cancel(); fail("resource", "language pack exceeds 4 MiB"); }
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const raw = new Uint8Array(size); let offset = 0;
|
||||
for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.length; }
|
||||
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); }
|
||||
catch { fail("load", "invalid language pack JSON"); }
|
||||
}
|
||||
50
i18n/browser/runtime.test.mjs
Normal file
50
i18n/browser/runtime.test.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLocalizer, createLanguageController, overlayPack, fetchPack } from "./runtime.mjs";
|
||||
|
||||
const pack = locale => ({ version: 1, locale, ruleVersion: "cldr-46.1", pluralRules: { en: { one: "i = 1 and v = 0" }, fr: { one: "i = 0,1" }, de: { one: "i = 1 and v = 0" } }, config: { sourceLocale: "en", defaultLocale: "en", locales: ["en", "fr", "de"] }, documents: [
|
||||
{ version: 1, locale: "en", namespace: "ui", messages: { title: { value: { text: "English" } }, greet: { params: { name: "string" }, value: { text: "Hello {name}" } } } },
|
||||
... (locale === "en" ? [] : [{ version: 1, locale, namespace: "ui", messages: { title: { value: { text: locale } } } }])
|
||||
] });
|
||||
|
||||
test("detached resources, explicit overrides, text arguments and fallback", () => {
|
||||
const data = pack("fr"), localizer = createLocalizer(data);
|
||||
data.documents[0].messages.greet.value.text = "changed";
|
||||
assert.equal(localizer.t("ui", "greet", { name: "<b>{name}</b>" }), "Hello <b>{name}</b>");
|
||||
assert.equal(localizer.render("ui", "greet", { name: "x" }).locale, "en");
|
||||
const original = pack("en");
|
||||
const overridden = overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { title: { value: { text: "Custom" } } } }]);
|
||||
assert.equal(createLocalizer(overridden).t("ui", "title"), "Custom");
|
||||
assert.equal(createLocalizer(original).t("ui", "title"), "English");
|
||||
assert.throws(() => overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { unknown: { value: {} } } }]));
|
||||
});
|
||||
|
||||
test("latest selection wins and failures preserve committed state", async () => {
|
||||
const pending = new Map();
|
||||
const controller = createLanguageController(pack("en"), locale => new Promise((resolve, reject) => pending.set(locale, { resolve, reject })));
|
||||
const french = controller.setLanguage("fr"), german = controller.setLanguage("de");
|
||||
pending.get("de").resolve(pack("de")); assert.equal(await german, true);
|
||||
pending.get("fr").resolve(pack("fr")); assert.equal(await french, false);
|
||||
assert.equal(controller.current.locale, "de");
|
||||
const failed = controller.setLanguage("fr"); pending.get("fr").reject(Error("offline"));
|
||||
await assert.rejects(failed); assert.equal(controller.current.locale, "de");
|
||||
const stale = controller.setLanguage("fr"); await controller.setLanguage("de");
|
||||
pending.get("fr").reject(Error("stale failure")); assert.equal(await stale, false);
|
||||
assert.equal(controller.current.t("ui", "title"), "de");
|
||||
});
|
||||
|
||||
test("bad packs cannot commit or change enabled languages/schemas", async () => {
|
||||
const controller = createLanguageController(pack("en"), async () => { const data = pack("fr"); delete data.documents[0].messages.greet; return data; });
|
||||
await assert.rejects(controller.setLanguage("fr")); assert.equal(controller.current.locale, "en");
|
||||
await assert.rejects(controller.setLanguage("zz"));
|
||||
const cycle = pack("en"); cycle.config.fallbacks = { en: ["fr"], fr: ["en"] }; assert.throws(() => createLocalizer(cycle));
|
||||
const duplicate = pack("en"); duplicate.documents.push(duplicate.documents[0]); assert.throws(() => createLocalizer(duplicate));
|
||||
});
|
||||
|
||||
test("bounded fetching and validation before switching", async () => {
|
||||
const data = await fetchPack("/en.json", { fetch: async () => new Response(JSON.stringify(pack("en"))) });
|
||||
assert.equal(createLocalizer(data).locale, "en");
|
||||
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad", { status: 500 }) }));
|
||||
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad") }));
|
||||
await assert.rejects(fetchPack("/huge", { fetch: async () => new Response(new Uint8Array(4194305)) }));
|
||||
});
|
||||
26
i18n/browser/testdata/consumer.mts
vendored
Normal file
26
i18n/browser/testdata/consumer.mts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
import {createLocalizer, createLanguageController, fetchPack, overlayPack} from './runtime.mjs';
|
||||
import type {Pack} from './runtime.mjs';
|
||||
import type {Translate} from './messages.generated.mjs';
|
||||
|
||||
declare const pack: Pack;
|
||||
const messages = createLocalizer(pack);
|
||||
const t: Translate = messages.t;
|
||||
t('common', 'greeting', {first_name: 'Alex'});
|
||||
t('common', 'count', {count: 9223372036854775807n});
|
||||
t('common', 'count', {count: '9223372036854775807'});
|
||||
t('common', 'literal', {});
|
||||
// @ts-expect-error a message without parameters accepts only an empty object
|
||||
t('common', 'literal', {extra: 1});
|
||||
// @ts-expect-error missing parameter
|
||||
t('common', 'greeting', {});
|
||||
// @ts-expect-error invalid parameter type
|
||||
t('common', 'greeting', {first_name: false});
|
||||
// @ts-expect-error undeclared parameter
|
||||
t('common', 'greeting', {first_name: 'Alex', secret: 'extra'});
|
||||
// @ts-expect-error unknown key
|
||||
t('common', 'unknown', {});
|
||||
// @ts-expect-error unknown namespace
|
||||
t('missing', 'literal', {});
|
||||
const controller = createLanguageController(pack, locale => fetchPack('/' + locale + '.json'));
|
||||
void controller.setLanguage('en');
|
||||
overlayPack(pack, [{version: 1, locale: 'en', namespace: 'common', messages: {literal: {value: {text: 'Custom'}}}}]);
|
||||
434
i18n/bundle.go
Normal file
434
i18n/bundle.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
schema map[string]Kind
|
||||
patterns map[string]*compiled
|
||||
}
|
||||
|
||||
// Bundle owns detached, compiled resources and is safe for concurrent reads.
|
||||
// Reconfiguration constructs another Bundle; no global current language exists.
|
||||
type Bundle struct {
|
||||
config Config
|
||||
matcher language.Matcher
|
||||
locales []string
|
||||
tags map[string]language.Tag
|
||||
entries map[Ref]*entry
|
||||
documents []Document
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
func validID(s string) bool {
|
||||
return s != "" && len(s) <= 256 && utf8.ValidString(s) && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0
|
||||
}
|
||||
func canonical(s string) (string, error) {
|
||||
if s == "" || len(s) > 128 {
|
||||
return "", fmt.Errorf("i18n: language tag required (maximum 128 bytes)")
|
||||
}
|
||||
t, err := language.Parse(s)
|
||||
if err != nil || t == language.Und {
|
||||
return "", fmt.Errorf("i18n: invalid language tag")
|
||||
}
|
||||
return t.String(), nil
|
||||
}
|
||||
|
||||
func normalizeConfig(c Config) (Config, error) {
|
||||
var err error
|
||||
if c.SourceLocale, err = canonical(c.SourceLocale); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.DefaultLocale, err = canonical(c.DefaultLocale); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len(c.Locales) == 0 || len(c.Locales) > 64 {
|
||||
return Config{}, fmt.Errorf("i18n: enable between 1 and 64 locales")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
locales := make([]string, 0, len(c.Locales))
|
||||
for _, raw := range c.Locales {
|
||||
l, e := canonical(raw)
|
||||
if e != nil {
|
||||
return Config{}, e
|
||||
}
|
||||
if seen[l] {
|
||||
return Config{}, fmt.Errorf("i18n: duplicate normalized locale %s", l)
|
||||
}
|
||||
seen[l] = true
|
||||
locales = append(locales, l)
|
||||
}
|
||||
if !seen[c.DefaultLocale] {
|
||||
return Config{}, fmt.Errorf("i18n: default locale must be enabled")
|
||||
}
|
||||
c.Locales = locales
|
||||
fallbacks := map[string][]string{}
|
||||
for _, raw := range sortedKeys(c.Fallbacks) {
|
||||
l, e := canonical(raw)
|
||||
if e != nil || !seen[l] {
|
||||
return Config{}, fmt.Errorf("i18n: fallback locale must be enabled")
|
||||
}
|
||||
if _, ok := fallbacks[l]; ok {
|
||||
return Config{}, fmt.Errorf("i18n: duplicate normalized fallback locale")
|
||||
}
|
||||
if len(c.Fallbacks[raw]) > 64 {
|
||||
return Config{}, fmt.Errorf("i18n: too many fallback locales")
|
||||
}
|
||||
list := []string{}
|
||||
unique := map[string]bool{}
|
||||
for _, v := range c.Fallbacks[raw] {
|
||||
to, e := canonical(v)
|
||||
if e != nil || !seen[to] || unique[to] {
|
||||
return Config{}, fmt.Errorf("i18n: fallback targets must be distinct enabled locales")
|
||||
}
|
||||
unique[to] = true
|
||||
list = append(list, to)
|
||||
}
|
||||
fallbacks[l] = list
|
||||
}
|
||||
state := map[string]uint8{}
|
||||
var visit func(string) error
|
||||
visit = func(l string) error {
|
||||
if state[l] == 1 {
|
||||
return fmt.Errorf("i18n: fallback cycle")
|
||||
}
|
||||
if state[l] == 2 {
|
||||
return nil
|
||||
}
|
||||
state[l] = 1
|
||||
for _, to := range fallbacks[l] {
|
||||
if err := visit(to); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state[l] = 2
|
||||
return nil
|
||||
}
|
||||
for _, l := range locales {
|
||||
if err := visit(l); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
c.Fallbacks = fallbacks
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error { _, err := normalizeConfig(c); return err }
|
||||
|
||||
// New accepts split documents; duplicate (locale, namespace, key) definitions
|
||||
// are rejected. Use Overlay explicitly when replacement is intended.
|
||||
func New(config Config, documents ...Document) (*Bundle, error) {
|
||||
c, err := normalizeConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := &Bundle{config: c, tags: map[string]language.Tag{}, entries: map[Ref]*entry{}}
|
||||
b.locales = append([]string{c.DefaultLocale}, c.Locales...)
|
||||
b.locales = unique(b.locales)
|
||||
tags := make([]language.Tag, 0, len(b.locales))
|
||||
for _, l := range b.locales {
|
||||
t := language.MustParse(l)
|
||||
tags = append(tags, t)
|
||||
b.tags[l] = t
|
||||
}
|
||||
b.matcher = language.NewMatcher(tags)
|
||||
if len(documents) > 1024 {
|
||||
return nil, fmt.Errorf("i18n: too many resource documents")
|
||||
}
|
||||
groups := map[string]map[string]map[string]Message{}
|
||||
total := 0
|
||||
names := map[string]bool{}
|
||||
languages := map[string]bool{}
|
||||
for _, doc := range documents {
|
||||
l, e := canonical(doc.Locale)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if doc.Version != Version || !validID(doc.Namespace) {
|
||||
return nil, fmt.Errorf("i18n: version 1 and a valid namespace are required")
|
||||
}
|
||||
names[doc.Namespace] = true
|
||||
languages[l] = true
|
||||
if len(names) > 128 || len(languages) > 64 {
|
||||
return nil, fmt.Errorf("i18n: namespace or locale resource limit exceeded")
|
||||
}
|
||||
if groups[l] == nil {
|
||||
groups[l] = map[string]map[string]Message{}
|
||||
}
|
||||
if groups[l][doc.Namespace] == nil {
|
||||
groups[l][doc.Namespace] = map[string]Message{}
|
||||
}
|
||||
for _, k := range sortedKeys(doc.Messages) {
|
||||
if !validID(k) {
|
||||
return nil, fmt.Errorf("i18n: invalid message key")
|
||||
}
|
||||
if _, ok := groups[l][doc.Namespace][k]; ok {
|
||||
return nil, &Diagnostic{Kind: "duplicate", Namespace: doc.Namespace, Key: k, Locale: l, Problem: "use an explicit overlay to replace a message"}
|
||||
}
|
||||
total++
|
||||
if total > 10000 {
|
||||
return nil, fmt.Errorf("i18n: resource exceeds 10000 messages")
|
||||
}
|
||||
groups[l][doc.Namespace][k] = doc.Messages[k]
|
||||
}
|
||||
}
|
||||
for _, ns := range sortedKeys(groups[c.SourceLocale]) {
|
||||
for _, key := range sortedKeys(groups[c.SourceLocale][ns]) {
|
||||
m := groups[c.SourceLocale][ns][key]
|
||||
if len(m.Params) > 64 {
|
||||
return nil, fmt.Errorf("i18n: message exceeds 64 parameters")
|
||||
}
|
||||
schema := map[string]Kind{}
|
||||
for name, kind := range m.Params {
|
||||
if !paramName.MatchString(name) || len(name) > 128 {
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "invalid parameter name"}
|
||||
}
|
||||
switch kind {
|
||||
case String, Boolean, Integer, Number:
|
||||
default:
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "unsupported type"}
|
||||
}
|
||||
schema[name] = kind
|
||||
}
|
||||
b.entries[Ref{ns, key}] = &entry{schema: schema, patterns: map[string]*compiled{}}
|
||||
}
|
||||
}
|
||||
limits := &budget{}
|
||||
for _, l := range sortedKeys(groups) {
|
||||
for _, ns := range sortedKeys(groups[l]) {
|
||||
doc := Document{Version: Version, Locale: l, Namespace: ns, Messages: map[string]Message{}}
|
||||
for _, key := range sortedKeys(groups[l][ns]) {
|
||||
m := groups[l][ns][key]
|
||||
e := b.entries[Ref{ns, key}]
|
||||
if e == nil {
|
||||
return nil, &Diagnostic{Kind: "unknown", Namespace: ns, Key: key, Locale: l, Problem: "message has no source definition"}
|
||||
}
|
||||
if m.Params != nil && !sameSchema(m.Params, e.schema) {
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Locale: l, Problem: "translation parameter schema differs from source"}
|
||||
}
|
||||
used := map[string]bool{}
|
||||
p, err := compile(m.Value, e.schema, used, limits, 0)
|
||||
if err == nil {
|
||||
for _, name := range sortedKeys(e.schema) {
|
||||
if !used[name] {
|
||||
err = &Diagnostic{Kind: "parameter", Param: name, Problem: "declared parameter is unused in this message"}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
d := *err.(*Diagnostic)
|
||||
d.Namespace, d.Key, d.Locale = ns, key, l
|
||||
return nil, &d
|
||||
}
|
||||
e.patterns[l] = p
|
||||
doc.Messages[key] = Message{Params: cloneSchema(e.schema), Value: clonePattern(m.Value)}
|
||||
}
|
||||
b.documents = append(b.documents, doc)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func unique(in []string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func sameSchema(a, b map[string]Kind) bool {
|
||||
return len(a) == len(b) && reflect.DeepEqual(cloneSchema(a), cloneSchema(b))
|
||||
}
|
||||
func cloneSchema(m map[string]Kind) map[string]Kind {
|
||||
out := map[string]Kind{}
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
func clonePattern(p Pattern) Pattern {
|
||||
if p.Cases != nil {
|
||||
m := make(map[string]Pattern, len(p.Cases))
|
||||
for k, v := range p.Cases {
|
||||
m[k] = clonePattern(v)
|
||||
}
|
||||
p.Cases = m
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (b *Bundle) Config() Config {
|
||||
if b == nil {
|
||||
return Config{}
|
||||
}
|
||||
c := b.config
|
||||
c.Locales = append([]string(nil), c.Locales...)
|
||||
c.Fallbacks = map[string][]string{}
|
||||
for k, v := range b.config.Fallbacks {
|
||||
c.Fallbacks[k] = append([]string{}, v...)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Match accepts ordered language tags, not an HTTP header. It returns an enabled
|
||||
// dictionary tag (not a synthetic matcher tag with user extensions).
|
||||
func (b *Bundle) Match(preferences ...string) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
tags := make([]language.Tag, 0, len(preferences))
|
||||
for _, s := range preferences {
|
||||
if len(tags) >= 64 {
|
||||
break
|
||||
}
|
||||
if len(s) > 128 {
|
||||
continue
|
||||
}
|
||||
if t, e := language.Parse(s); s != "" && e == nil {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
_, index, _ := b.matcher.Match(tags...)
|
||||
return b.locales[index]
|
||||
}
|
||||
|
||||
func (b *Bundle) chain(locale string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
var add func(string)
|
||||
add = func(l string) {
|
||||
if seen[l] {
|
||||
return
|
||||
}
|
||||
seen[l] = true
|
||||
out = append(out, l)
|
||||
for _, to := range b.config.Fallbacks[l] {
|
||||
add(to)
|
||||
}
|
||||
}
|
||||
add(locale)
|
||||
add(b.config.DefaultLocale)
|
||||
add(b.config.SourceLocale)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Bundle) Render(locale string, ref Ref, params map[string]any) (Result, error) {
|
||||
if b == nil {
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Problem: "no bundle"}
|
||||
}
|
||||
requested := b.Match(locale)
|
||||
e := b.entries[ref]
|
||||
if e == nil {
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "unknown message"}
|
||||
}
|
||||
values := make(map[string]string, len(e.schema))
|
||||
for _, k := range sortedKeys(e.schema) {
|
||||
v, ok := params[k]
|
||||
if !ok {
|
||||
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "missing argument"}
|
||||
}
|
||||
s, err := scalar(e.schema[k], v)
|
||||
if err != nil {
|
||||
d := *err.(*Diagnostic)
|
||||
d.Namespace, d.Key, d.Param = ref.Namespace, ref.Key, k
|
||||
return Result{}, &d
|
||||
}
|
||||
values[k] = s
|
||||
}
|
||||
for _, k := range sortedKeys(params) {
|
||||
if _, ok := e.schema[k]; !ok {
|
||||
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "undeclared argument"}
|
||||
}
|
||||
}
|
||||
for _, l := range b.chain(requested) {
|
||||
if p := e.patterns[l]; p != nil {
|
||||
text, err := p.render(language.MustParse(l), values)
|
||||
return Result{Text: text, Requested: requested, Locale: l, Fallback: l != requested}, err
|
||||
}
|
||||
}
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "no message in fallback chain"}
|
||||
}
|
||||
func (b *Bundle) RenderContext(ctx context.Context, ref Ref, params map[string]any) (Result, error) {
|
||||
return b.Render(Locale(ctx), ref, params)
|
||||
}
|
||||
|
||||
// Schema returns a detached source schema for adapters and static tools.
|
||||
func (b *Bundle) Schema(ref Ref) (map[string]Kind, bool) {
|
||||
if b == nil {
|
||||
return nil, false
|
||||
}
|
||||
e := b.entries[ref]
|
||||
if e == nil {
|
||||
return nil, false
|
||||
}
|
||||
return cloneSchema(e.schema), true
|
||||
}
|
||||
|
||||
// Check reports missing direct translations. Runtime fallback remains usable;
|
||||
// build tooling can reject these diagnostics without changing runtime behavior.
|
||||
func (b *Bundle) Check() []Diagnostic {
|
||||
out := []Diagnostic{}
|
||||
if b == nil {
|
||||
return out
|
||||
}
|
||||
for _, d := range b.documents {
|
||||
if d.Locale != b.config.SourceLocale {
|
||||
continue
|
||||
}
|
||||
for _, key := range sortedKeys(d.Messages) {
|
||||
e := b.entries[Ref{d.Namespace, key}]
|
||||
for _, l := range b.config.Locales {
|
||||
if e.patterns[l] == nil {
|
||||
out = append(out, Diagnostic{Kind: "missing", Namespace: d.Namespace, Key: key, Locale: l, Problem: "direct translation missing"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Documents returns detached resources. An empty namespace filter includes all;
|
||||
// exporting for a browser should normally supply explicit namespaces.
|
||||
func (b *Bundle) Documents(locale string, namespaces ...string) []Document {
|
||||
out := []Document{}
|
||||
if b == nil {
|
||||
return out
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
for _, ns := range namespaces {
|
||||
wanted[ns] = true
|
||||
}
|
||||
for _, d := range b.documents {
|
||||
if locale != "" && d.Locale != locale || len(wanted) > 0 && !wanted[d.Namespace] {
|
||||
continue
|
||||
}
|
||||
copy := d
|
||||
copy.Messages = map[string]Message{}
|
||||
for k, m := range d.Messages {
|
||||
copy.Messages[k] = Message{Params: cloneSchema(m.Params), Value: clonePattern(m.Value)}
|
||||
}
|
||||
out = append(out, copy)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user