Compare commits

...

6 Commits

113 changed files with 9217 additions and 88 deletions

View File

@@ -1,5 +1,32 @@
# Changelog
## 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,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
- 回调失败后只有显式回滚返回成功才标记 rolledBack;提交报错一律保守返回 unknown,不根据断网/超时或 ErrTxDone 推断回滚。不自动重试事务,不处理文件清理等业务决策。
- Err/RollbackErr 分开保留,errors.As/Is 可遍历两条原因链;业务错误目录和公开响应行为保持。nil context 接受,nil 回调在开启前报错,panic/Goexit 保持传播并尝试回滚。
- 新增阶段/取消竞争/确认丢失/并发/公开响应回归、真实 MySQL 驱动边界故障验收、性能比较和 examples/transactions;同步 README 与 database 指南。
- 错误现在有类型包装;直接错误相等、直接类型断言和错误文本依赖需改为 errors.Is/As。标准库异步回滚不公开的底层原因无法还原,结果保持 unknown;不改变 GORM 自己的 Transaction。
## v0.24.0-preview(2026-09-26)
- 新增可选 cors 包和 server.cors 配置;生成项目直接加载,手写项目使用 omni.WithCORS,独立 HTTP 服务可使用 Policy.Wrap。默认关闭,无新增第三方依赖。
- 精确来源白名单、方法/请求头、暴露头、凭据和预检缓存可配置;拒绝凭据与通配来源混用、非法来源/头及歧义的重复请求头,校验错误定位具体字段。
- 在路由和鉴权之前处理预检;实际请求仍执行原有中间件,统一响应、404/405、上传下载和流式能力保持。nethttp/native/HTTP2 共用策略。
- 补充真实生成项目、TLS 三种 transport、20 项真实浏览器检查、race/fuzz/性能基准;同步 README、配置指南、cors 指南及 examples/cors。
- 没有移除现有功能或修改默认跨域行为。代理已负责 CORS 的项目保持关闭,避免重复头;高级调用 Router().ServeHTTP 不经过应用外层 CORS。
## v0.23.0-preview(2026-09-26)
- MySQL/连接池、日志、HTTP 客户端、生命周期和请求策略返回字段级安全诊断,配置加载器保留文件/变量/位置与原始错误链。

View File

@@ -189,3 +189,11 @@ Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量
5. 升级匹配的 CLI 与框架,执行 generate、generate --check、go test、go build 和 `-check-config`,再使用真实部署配置启动。
低层 `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)。

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.23.0-preview)
# 通用业务开发指南(v0.26.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。

View File

@@ -22,7 +22,7 @@ app.GET("/metrics", measurements.Handler())
// 也可以直接读取 measurements.Snapshot()。
```
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。CORS 由项目中间件或代理配置,当前没有内置 CORS 模块。
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。需要跨域时配置 `server.cors`,由框架在路由与鉴权前处理预检;默认关闭。已有代理 CORS 可继续使用,避免两处重复设置。见 [CORS 配置与示例](cors/README.md)。
## 缓存:cache 与 cache/redis

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界
v0.23.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
v0.26.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
@@ -215,7 +215,7 @@ get /download (DownloadReq)
## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.23.0-preview:
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.26.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 和提前返回的中间件一起验收。

View File

@@ -2,7 +2,13 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.23.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.26.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
**从这份 README 开始接入。** 当前版本 **`v0.26.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
**v0.24 改动**:新增可选 CORS,生成项目通过 `server.cors` 自动接入,支持预检、错误响应和明确的来源白名单;默认关闭。[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)。
**v0.23 改动**:配置校验定位具体字段和安全原因;`httpc.JSON` 可选限制成功状态;`copyx.Check` 无需样本预检类型;`logx` 支持实例独立时区。[四项完整示例](examples/developerTools/README.md)。
@@ -33,16 +39,18 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
| 浏览器跨域 CORS | `server.cors` / `omni.WithCORS`;独立 HTTP 用 `cors.New(...).Wrap(handler)` | [配置、默认值与协议边界](cors/README.md)、[鉴权与预检示例](examples/cors/README.md) |
| HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) |
| 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) |
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) |
| 公共响应与特殊协议 | `.api` 定义 response 并 `@app(defaultResponse: ...)`;Go 用 `WithResponse` / Encoder | [响应配置](RESPONSE.md)、[响应模板契约](examples/contracts/responses.api) |
| 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) |
| 通用国际化 | `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#配置与日志热更新) |
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;`InTx` 的 `TxError` 提供阶段、已知结果与原因链 | [通用数据库](database/README.md)、[事务失败示例](examples/transactions/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
@@ -56,6 +64,26 @@ 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) |
## 浏览器跨域 CORS
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
```yaml
server:
cors:
enabled: true
allowedOrigins: [https://admin.example.com, https://merchant.example.com]
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
allowedHeaders: [Authorization, Content-Type]
exposedHeaders: [Content-Disposition, X-Request-ID]
allowCredentials: false
maxAge: 10m
```
不需要修改 main、Logic 或 `.api`。框架在鉴权前处理合法预检;实际请求仍鉴权,401/404/405/500 保持项目响应格式并附带允许来源的跨域头。没有 Origin 的服务端回调正常处理。配置只决定浏览器读取权限,不替代登录、验签或 CSRF 防护。
手写项目用 `omni.WithCORS(omni.CORSConfig{...})`,独立 HTTP 服务用 `cors.New(...).Wrap(handler)`;代理已经处理 CORS 时保持关闭。完整 [默认值与边界](cors/README.md)、[可运行示例](examples/cors/README.md)。
## 带认证的出站 HTTP
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
@@ -128,7 +156,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.23.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -285,7 +313,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.23.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -611,7 +639,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.23.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.26.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入

View File

@@ -1,4 +1,74 @@
# v0.23.0-preview
# 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 ./...
```
---
# v0.25.0-preview
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
- `database.InTx` 签名不变,普通错误返回 `*database.TxError`;Stage 为 begin/work/commit,Outcome 为 notStarted/rolledBack/unknown。成功提交仍返回 nil。
- Commit 报错一律 unknown;回调失败且显式 Rollback 返回 nil 才标记 rolledBack。BeginTx 失败不执行回调。ErrTxDone 不作为回滚成功证据,框架不重试事务。
- `Err` 与 `RollbackErr` 保留两条原因链,可通过 errors.Is/As 识别;APIError、目录参数提示和公开响应继续工作。内部原因不自动进入 HTTP 提示。
- 提供无需外部数据库的 [故障演示](examples/transactions/README.md)。真实 MySQL 与性能结果见 [验收记录](reports/transactionAcceptance.md)。不引入新依赖,不包含文件删除、补偿任务或业务规则。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
go-omni api generate --check
go test ./...
```
**兼容边界:** 既有 errors.Is/As 保持;直接 `err == sentinel`、直接类型断言或字符串匹配需调整。nil context 使用 Background,nil 回调在开启前返回 typed error。panic/Goexit 继续传播并尝试回滚,不转换为 error;标准库自动回滚不公开的原因无法还原,不能推断已回滚。结果仅覆盖按约定使用的当前事务,不覆盖非事务表、隐式提交 SQL、其他连接或外部副作用。GORM 原生 Transaction 没有被替换。
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
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(历史版本)
业务项目通过配置开启跨域,不需要手写 OPTIONS Handler 或逐条修改接口。
- 默认关闭,在 `server.cors` 显式配置来源、方法和请求头;统一文件加载、环境引用和字段级诊断自动生效。生成项目无需修改 main、svc 或 `.api`。
- 预检在鉴权前返回 204/403 空响应;实际请求继续鉴权、业务错误编码和路由处理。允许来源可读取 401/403/404/405/500,上传下载和流式能力保留。
- 只支持精确 HTTP(S) 来源,公开无凭据服务可配置单一 `*`;禁止 `*` 与凭据混用。配置初始化快照、请求并发隔离、Vary 缓存分流和有界请求头处理。
- 同一个 App 策略覆盖标准库、native 和 HTTP2;独立服务可使用 `cors.New(...).Wrap(handler)`。没有新增第三方依赖,没有移除现有功能。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
现有项目不配置 CORS 时行为不变。代理已处理 CORS 的项目继续关闭;跨域不替代登录、权限或 CSRF 防护。通过 App/Engine/Run 接入,直接对外暴露底层 Router 会绕过应用外层策略。本轮新增 runtime 配置需 v0.24,未使用该配置的模板最低版本仍为 v0.22。
[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)、[验收与性能](reports/corsAcceptance.md) 已同步。49 个测试包全量 race、Go 1.22、vet/build、实际生成项目、三种 transport 的 TLS 场景和 20 项真实浏览器检查通过;30 秒 fuzz 约 288 万次。当前仍为 preview。
空缓存安装远程版本后,9 组生成消费者和 70 项发布检查通过,耗时 66.78 秒,无本地 replace;包括下载模块的 cors/app race、三种 transport 的 TLS 检查和只改配置的生成项目。标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`,见 [远程记录](reports/corsRemote.json)。
---
# v0.23.0-preview(历史版本)
减少业务接入时重复维护的配置校验、HTTP 包装和类型检查。
@@ -19,6 +89,8 @@ go run . -config etc/config.yaml -check-config
[完整示例](examples/developerTools/README.md)、[配置](CONFIG.md)、[JSON 选项](httpc/README.md)、[类型预检](COPY.md#类型预检)、[日志时区](logx/README.md#实例时区)。验收与性能记录见 [本轮验收](reports/developerToolsAcceptance.md)。仍为 preview。
48 个测试包全量 race、Go 1.22、vet/build、实际生成消费者及约 392 万次 copyx fuzz 通过。空缓存安装远程版本后,66 项发布检查通过,包括下载模块的 config/httpc/copyx/logx race;耗时 57.06 秒,无本地 replace。标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,见 [远程验收](reports/developerToolsRemote.json)。
---
# v0.22.0-preview(历史版本)

View File

@@ -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,运行时与生成器不再通过它固定响应结构。

View File

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

187
app/cors_test.go Normal file
View File

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

241
app/i18n_test.go Normal file
View 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())
}
}

View File

@@ -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)

View File

@@ -1,5 +1,7 @@
# go-omni CLI
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用同版 v0.26 CLI 与框架。
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
@@ -14,7 +16,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
```sh
# 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -49,14 +51,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.23.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.26.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.23.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.26.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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
}

View File

@@ -22,7 +22,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.23.0-preview"
const currentVersion = "v0.26.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
@@ -194,6 +194,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
}

View 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
}

View 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") }

View File

@@ -62,7 +62,10 @@ Ordinary Logic uses the generated Request/Response types directly. Where an
independent business type is needed, use ordinary Go or the optional copyx package.
Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use explicit ${NAME} / ${NAME:-fallback} references; fixed values are never overridden by inferred environment names. Use -check-config to validate without opening dependencies. Framework validation
identifies fields such as server.log.level and database.port. server.log.timeZone
optionally selects a per-logger IANA zone. See the framework CONFIG.md.
optionally selects a per-logger IANA zone. Optional server.cors is loaded and installed
automatically before routing/authentication; disabled by default. Configure allowedOrigins,
allowedMethods and allowedHeaders in the selected file; no main/handler edits.
See the framework CONFIG.md and cors/README.md.
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
See COPY.md in the framework repository for copy policies and migration steps,

View File

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

View File

@@ -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")
}
}

View File

@@ -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{

195
cmd/go-omni/i18n.go Normal file
View 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
View 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)
}
}

View File

@@ -18,6 +18,11 @@ func main() {
}
switch os.Args[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 +175,7 @@ func usage() {
fmt.Fprintln(os.Stderr, "GoOmni project tool")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Usage:")
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>")

View File

@@ -1,5 +1,6 @@
# Only fields listed here override Default(). Environment names are explicit.
server:
# Optional cross-origin access: server.cors (disabled by default). See framework cors/README.md.
address: "${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}"
transport: "${GO_OMNI_TRANSPORT:-nethttp}"
{{if ne .Storage "none"}}storage:

View File

@@ -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.23.0-preview"
const defaultFrameworkVersion = "v0.26.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.

View File

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

51
config/cors_test.go Normal file
View File

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

View File

@@ -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

View File

@@ -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"`

82
cors/README.md Normal file
View File

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

144
cors/config.go Normal file
View File

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

187
cors/cors.go Normal file
View File

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

303
cors/cors_test.go Normal file
View File

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

View File

@@ -9,7 +9,7 @@
- `Config`:驱动、DSN 与连接池参数;嵌入项目配置,通过文件显式引用变量。
- `Open` / `OpenConnector`:通过 DSN 或标准 Connector 创建连接池、设置连接上限并在返回前执行 `PingContext`。
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时尝试回滚。返回 `TxError` 区分阶段与结果,提交错误不代表已回滚。
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动:
@@ -63,6 +63,55 @@ python3 scripts/verifyDatabase.py --version v0.2.0
配置结构作为项目 Config 的字段,Default() 初始化连接池,文件按需覆盖。DSN 可使用 `dsn: "${DB_DSN}"`;database.Config 的 JSON 序列化不输出 DSN 原值。不要打印原始配置对象。当前 API 脚手架使用结构化 MySQL 适配器,由 svc 统一初始化与关闭。
## 事务阶段与结果
v0.25 起,`db.InTx(ctx, options, work)` 签名不变,所有普通错误返回都是 `*database.TxError`。返回 nil 表示 Commit 返回成功;框架不重试回调、提交或整个事务。业务通过标准库 errors.As/Is 判断,不解析错误文本:
```go
err := db.InTx(ctx, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "INSERT INTO objects (id, storage_key) VALUES (?, ?)", id, key)
return err
})
if err != nil {
var txErr *database.TxError
if errors.As(err, &txErr) {
switch txErr.Outcome {
case database.TxOutcomeNotStarted, database.TxOutcomeRolledBack:
// 当前受管理事务未执行回调或已确认回滚。
// 业务结合文件归属、并发操作和其他副作用,决定是否清理。
default:
// 包括 TxOutcomeUnknown:保留文件,交给业务后续核查。
// 不因为收到提交错误就删除文件或直接重试整个操作。
}
// txErr.Stage 是 begin / work / commit。
// txErr.Err 是主要原因;txErr.RollbackErr 是回滚调用返回的原因。
}
if errors.Is(err, context.DeadlineExceeded) {
// 说明错误链包含超时,不意味着数据库没有提交。
}
return err
}
```
`errors` 为标准库包,业务自己的数据库驱动和 SQL 参数规则不变。完整可运行示例见 [examples/transactions](../examples/transactions/README.md)。
| 情况 | Stage | Outcome | RollbackErr |
| --- | --- | --- | --- |
| 未初始化数据库、nil 回调、BeginTx 报错 | TxStageBegin | TxOutcomeNotStarted | nil;框架没有拿到事务,不执行回调 |
| 回调报错,显式 Rollback 成功 | TxStageWork | TxOutcomeRolledBack | nil |
| 回调报错,Rollback 失败或返回 ErrTxDone | TxStageWork | TxOutcomeUnknown | 原始回滚错误 |
| Commit 报错,包括取消/断网/驱动错误 | TxStageCommit | TxOutcomeUnknown | 后续清理的 Rollback 返回值,可能为 ErrTxDone 或 nil |
`Stage` 描述主要失败发生的位置;`Outcome` 只描述框架掌握的结果,二者独立。提交调用返回错误后一律保守标为 unknown,即使某个驱动的某种错误意味着没有提交,通用层也不根据错误文本猜测。标准库在 Commit 后通常已经结束本地事务对象,后续 Rollback 返回 `sql.ErrTxDone`;它不能证明数据库已回滚,框架保留该原因。
`TxError.Err` 与 `RollbackErr` 分开保存;`Unwrap() []error` 按主要原因、回滚原因顺序暴露,所以 errors.Is/As 可查两条链。需要区分来源时对 `txErr.Err` 或 `txErr.RollbackErr` 单独判断。回调返回另一个事务的 TxError 时外层不把它压平:外层结果只属于外层事务,内部事务的结果仍在 Err 中。
**结果保证的范围:** 回调使用给定 `*sql.Tx` 完成事务操作,不自行 Commit/Rollback,不执行会隐式提交的 SQL,不将事务交给后台 goroutine。非事务表、其他连接、嵌套的独立事务、文件和第三方调用的副作用不在这份保证内。框架没有删除文件、入队补偿或分布式事务规则。
**取消与异常边界:** context 取消时 database/sql 可能自行异步回滚,且不公开那次驱动 Rollback 的原始错误。框架若只观察到 ErrTxDone,就返回 unknown,不能补造成功证据或不存在的底层原因。正常返回路径上,框架实际观察到的主要/回滚原因全部保留。panic/runtime.Goexit 保持原有传播行为并尝试回滚,不转换为普通 error;这类展开没有错误返回值,因此不能把回滚原因作为返回值交给调用方。nil context 使用 Background,nil 回调在开启事务前报错。
**兼容说明:** 已有 errors.Is/As 继续生效;以前直接用 `err == sentinel`、`err.(*SomeError)` 或匹配文本的调用要改成 errors.Is/As。业务 APIError、Go 错误目录和参数提示仍可穿过包装解析,驱动错误不会自动进入公开提示。TxError.Error 包含内部原因,仅供内部诊断。GORM 自己的 Transaction 不经过本方法,也不会自动变成 TxError;可选 gormx 的连接池接入不变。
## Connector 接入(v0.9.1-preview)
项目已有 `database/sql/driver.Connector` 时使用 `OpenConnector`。这是标准驱动接口,核心包不增加具体驱动依赖。项目可以在同一进程创建多个使用不同证书、日志、拨号策略的连接池。

View File

@@ -107,27 +107,3 @@ func (db *DB) Health(ctx context.Context) error {
defer cancel()
return db.PingContext(checkCtx)
}
// InTx commits only when work returns nil. Any work error or panic rolls back.
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
if db == nil || db.DB == nil {
return errors.New("database is not initialized")
}
tx, err := db.BeginTx(ctx, options)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
rollbackErr := tx.Rollback()
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
}
}()
if err = work(tx); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}

View File

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

126
database/transaction.go Normal file
View File

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

View File

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

View File

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

View File

@@ -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 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
## 基本规则

View File

@@ -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
View 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
}

View 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)
}

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

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

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

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

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

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

28
examples/i18n/README.md Normal file
View 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)。

View File

@@ -0,0 +1,6 @@
{
"sourceLocale": "en",
"defaultLocale": "en",
"locales": ["en", "zh-Hans"],
"fallbacks": {"zh-Hans": ["en"]}
}

46
examples/i18n/index.html Normal file
View 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>

View File

@@ -0,0 +1,8 @@
{
"version": 1,
"namespace": "errors",
"locale": "zh-Hans",
"messages": {
"LIMIT_EXCEEDED": {"value": {"text": "数量不能超过 {max}"}}
}
}

View 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"}
}}}
}
}

View 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
View 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
}

View 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())
}
}
}

View File

@@ -2,7 +2,7 @@ module example.com/managed
go 1.22.0
require git.yuntuo.hk/k2safe/go-omni v0.23.0-preview
require git.yuntuo.hk/k2safe/go-omni v0.26.0-preview
require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect

View File

@@ -0,0 +1,24 @@
# 事务失败与结果不确定
在框架根目录运行:
```sh
go run ./examples/transactions
```
示例使用确定性的演示 SQL 驱动,没有真实数据库、上传文件或外部服务。业务调用仍走真实 `database/sql` 和 `database.InTx`;`demoDriver.go` 仅用来注入数据库操作成功后确认丢失的情况,项目继续使用自己的正常驱动。
```text
success: committed, stored=true
beginFailure: stage=begin outcome=notStarted stored=false cleanup=true
workFailure: stage=work outcome=rolledBack stored=false cleanup=true
rollbackFailure: stage=work outcome=unknown stored=false cleanup=false
commitRejected: stage=commit outcome=unknown stored=false cleanup=false
commitAcknowledgementLost: stage=commit outcome=unknown stored=true cleanup=false
```
最后两个场景收到一样的提交错误,但一个没有保存、另一个已经保存。调用方不能从提交错误推断数据库没记录,因此示例只在已知未执行回调或已回滚时考虑清理,不确定时保留。`cleanup` 只是演示业务决策,没有执行任何删除;实际业务仍需考虑文件是否只属于本次操作、其他事务或外部副作用。
`errors.As` 取得 TxError 的阶段/结果,`errors.Is` 仍找到原始演示错误。回滚失败有独立原因;TxError 不规定业务码、HTTP 状态或清理策略。
完整接口、自动回滚的观察边界和兼容注意见 [database](../../database/README.md#事务阶段与结果)。真实 MySQL 和故障回归见 [验收记录](../../reports/transactionAcceptance.md)。

View File

@@ -0,0 +1,48 @@
package main
import (
"context"
"database/sql/driver"
"errors"
"sync/atomic"
)
// This driver models an acknowledgement lost AFTER persistence. Applications
// use their normal SQL driver; this is solely a runnable fault demonstration.
type demoConnector struct {
beginErr, commitErr, rollbackErr error
persistBeforeError bool
stored atomic.Bool
}
func (d *demoConnector) Connect(context.Context) (driver.Conn, error) {
return &demoConn{owner: d}, nil
}
func (d *demoConnector) Driver() driver.Driver { return d }
func (d *demoConnector) Open(string) (driver.Conn, error) { return d.Connect(context.Background()) }
type demoConn struct{ owner *demoConnector }
func (c *demoConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("not implemented by demo")
}
func (c *demoConn) Close() error { return nil }
func (c *demoConn) Begin() (driver.Tx, error) {
if c.owner.beginErr != nil {
return nil, c.owner.beginErr
}
return &demoTx{owner: c.owner}, nil
}
func (c *demoConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
return driver.RowsAffected(1), nil
}
type demoTx struct{ owner *demoConnector }
func (tx *demoTx) Commit() error {
if tx.owner.commitErr == nil || tx.owner.persistBeforeError {
tx.owner.stored.Store(true)
}
return tx.owner.commitErr
}
func (tx *demoTx) Rollback() error { return tx.owner.rollbackErr }

View File

@@ -0,0 +1,55 @@
// Run with go run ./examples/transactions. Uses a deterministic demonstration
// driver, no external database and no actual uploaded files.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/database"
)
func main() {
failure := errors.New("demonstration failure")
for _, name := range []string{"success", "beginFailure", "workFailure", "rollbackFailure", "commitRejected", "commitAcknowledgementLost"} {
driver := &demoConnector{}
var workErr error
switch name {
case "beginFailure":
driver.beginErr = failure
case "workFailure":
workErr = failure
case "rollbackFailure":
workErr, driver.rollbackErr = failure, failure
case "commitRejected":
driver.commitErr = failure
case "commitAcknowledgementLost":
driver.commitErr, driver.persistBeforeError = failure, true
}
pool := &database.DB{DB: sql.OpenDB(driver)}
err := pool.InTx(context.Background(), nil, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(context.Background(), "INSERT demonstration record"); err != nil {
return err
}
return workErr
})
_ = pool.Close()
if err == nil {
fmt.Printf("%s: committed, stored=%t\n", name, driver.stored.Load())
continue
}
var txErr *database.TxError
if !errors.As(err, &txErr) || !errors.Is(err, failure) {
panic("transaction phase or original cause lost")
}
// A project decides whether cleanup is appropriate for its own operation.
// Unknown outcomes must not be treated as evidence that no record exists.
cleanup := txErr.Outcome == database.TxOutcomeNotStarted || txErr.Outcome == database.TxOutcomeRolledBack
if cleanup && driver.stored.Load() {
panic("unsafe cleanup decision")
}
fmt.Printf("%s: stage=%s outcome=%s stored=%t cleanup=%t\n", name, txErr.Stage, txErr.Outcome, driver.stored.Load(), cleanup)
}
}

View File

@@ -3,7 +3,7 @@ module example.com/userService
go 1.22.0
require (
git.yuntuo.hk/k2safe/go-omni v0.23.0-preview
git.yuntuo.hk/k2safe/go-omni v0.26.0-preview
github.com/go-sql-driver/mysql v1.9.3
)

2
go.mod
View File

@@ -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
)

View File

@@ -88,15 +88,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 +137,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
}
@@ -301,16 +303,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) }

View File

@@ -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
View 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
View 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
View 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 }

View 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);

View 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
View 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"); }
}

View 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
View 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
View 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
}

353
i18n/bundle_test.go Normal file
View File

@@ -0,0 +1,353 @@
package i18n_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"math"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"testing/fstest"
"git.yuntuo.hk/k2safe/go-omni/i18n"
)
func settings() i18n.Config {
return i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans", "ru", "ar", "fr"}, Fallbacks: map[string][]string{"zh-Hans": {"en"}}}
}
func docs() []i18n.Document {
plural := i18n.Pattern{Plural: "count", Cases: map[string]i18n.Pattern{}}
for _, category := range []string{"zero", "one", "two", "few", "many", "other"} {
plural.Cases[category] = i18n.Pattern{Text: category + ":{count}"}
}
out := []i18n.Document{}
for _, locale := range settings().Locales {
out = append(out, i18n.Document{Version: 1, Namespace: "common", Locale: locale, Messages: map[string]i18n.Message{
"count": {Params: map[string]i18n.Kind{"count": i18n.Integer}, Value: plural},
"decimal": {Params: map[string]i18n.Kind{"count": i18n.Number}, Value: plural},
"choice": {Params: map[string]i18n.Kind{"type": i18n.String, "count": i18n.Integer}, Value: i18n.Pattern{Select: "type", Cases: map[string]i18n.Pattern{"admin": {Text: "admin:{count}"}, "other": plural}}},
"literal": {Value: i18n.Pattern{Text: "${HOME} {notAParameter}", Literal: true}},
"empty": {Value: i18n.Pattern{}},
}})
}
out[0].Messages["greeting"] = i18n.Message{Params: map[string]i18n.Kind{"first_name": i18n.String}, Value: i18n.Pattern{Text: "Hello {first_name} {{literal}}"}}
return out
}
func makeBundle(t testing.TB) *i18n.Bundle {
t.Helper()
b, err := i18n.New(settings(), docs()...)
if err != nil {
t.Fatal(err)
}
return b
}
func TestIndependentImmutableBundlesAndFallback(t *testing.T) {
resources := docs()
cfg := settings()
b, err := i18n.New(cfg, resources...)
if err != nil {
t.Fatal(err)
}
resources[0].Messages["greeting"] = i18n.Message{Value: i18n.Pattern{Text: "changed"}}
cfg.Locales[0] = "de"
cfg.Fallbacks["zh-Hans"][0] = "de"
snapshot := b.Config()
snapshot.Fallbacks["zh-Hans"][0] = "fr"
exported := b.Documents("")
exported[0].Messages["count"] = i18n.Message{}
result, err := b.RenderContext(i18n.WithLocale(context.Background(), "zh-CN"), i18n.Ref{Namespace: "common", Key: "greeting"}, map[string]any{"first_name": "{count}"})
if err != nil || result.Text != "Hello {count} {literal}" || result.Locale != "en" || !result.Fallback || result.Requested != "zh-Hans" {
t.Fatal(result, err)
}
if len(b.Check()) != 4 {
t.Fatal(b.Check())
}
if got := b.Match("ru-RU"); got != "ru" {
t.Fatal(got)
}
for _, key := range []string{"literal", "empty"} {
got, err := b.Render("en", i18n.Ref{Namespace: "common", Key: key}, nil)
if err != nil {
t.Fatal(err)
}
if key == "literal" && got.Text != "${HOME} {notAParameter}" || key == "empty" && got.Text != "" {
t.Fatal(got)
}
}
var wg sync.WaitGroup
for n := 0; n < 80; n++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
l, want := "en", "other:22"
if n%2 == 0 {
l, want = "ru", "few:22"
}
got, err := b.Render(l, i18n.Ref{Namespace: "common", Key: "count"}, map[string]any{"count": int64(22)})
if err != nil || got.Text != want {
t.Errorf("%+v %v", got, err)
}
}(n)
}
wg.Wait()
other := docs()
other[0].Messages["greeting"] = i18n.Message{Value: i18n.Pattern{Text: "different application"}}
b2, err := i18n.New(settings(), other...)
if err != nil {
t.Fatal(err)
}
got, _ := b2.Render("en", i18n.Ref{Namespace: "common", Key: "greeting"}, nil)
if got.Text != "different application" {
t.Fatal(got)
}
}
func TestResourceAndParameterDiagnostics(t *testing.T) {
b := makeBundle(t)
for _, params := range []map[string]any{nil, {"count": "21"}, {"count": 21.0}, {"count": 21, "extra": "secret"}, {"count": uint64(math.MaxUint64)}, {"count": []string{"secret"}}} {
_, err := b.Render("en", i18n.Ref{Namespace: "common", Key: "count"}, params)
var d *i18n.Diagnostic
if !errors.As(err, &d) || d.Kind != "parameter" || strings.Contains(err.Error(), "secret") {
t.Fatal(err)
}
}
_, err := b.Render("en", i18n.Ref{Namespace: "common", Key: "missing"}, nil)
var d *i18n.Diagnostic
if !errors.As(err, &d) || d.Kind != "missing" {
t.Fatal(err)
}
_, err = b.Render("en", i18n.Ref{Namespace: "common", Key: "decimal"}, map[string]any{"count": 1e-16})
if err != nil {
t.Fatal(err)
}
for _, change := range []func([]i18n.Document){
func(d []i18n.Document) { d[1].Messages["unknown"] = i18n.Message{} },
func(d []i18n.Document) {
m := d[1].Messages["count"]
m.Params = map[string]i18n.Kind{"count": i18n.String}
d[1].Messages["count"] = m
},
func(d []i18n.Document) {
m := d[1].Messages["count"]
m.Value = i18n.Pattern{Text: "{missing}"}
d[1].Messages["count"] = m
},
func(d []i18n.Document) {
m := d[1].Messages["count"]
m.Value = i18n.Pattern{Text: "unused"}
d[1].Messages["count"] = m
},
func(d []i18n.Document) {
m := d[1].Messages["count"]
delete(m.Value.Cases, "other")
d[1].Messages["count"] = m
},
func(d []i18n.Document) { d[1].Version = 2 },
} {
d := docs()
change(d)
if _, err := i18n.New(settings(), d...); err == nil {
t.Fatal("invalid document accepted")
}
}
for _, change := range []func(*i18n.Config){func(c *i18n.Config) { c.Fallbacks["en"] = []string{"zh-Hans"} }, func(c *i18n.Config) { c.DefaultLocale = "de" }, func(c *i18n.Config) { c.Locales = append(c.Locales, "EN") }} {
c := settings()
change(&c)
if _, err := i18n.New(c, docs()...); err == nil {
t.Fatal("invalid config accepted")
}
}
}
func TestSourceLocaleCanBeFallbackOnly(t *testing.T) {
// These tags also exercise browser portability: Intl can alias cmn to zh,
// and rejects private-use tags despite Go's BCP 47 matcher accepting them.
for _, locale := range []string{"fr", "cmn", "x-example"} {
t.Run(locale, func(t *testing.T) {
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: locale, Locales: []string{locale}},
i18n.Document{Version: 1, Namespace: "common", Locale: "en", Messages: map[string]i18n.Message{"fallback": {Value: i18n.Pattern{Text: "Source"}}}})
if err != nil {
t.Fatal(err)
}
got, err := b.Render(locale, i18n.Ref{Namespace: "common", Key: "fallback"}, nil)
if err != nil || got.Text != "Source" || !got.Fallback || got.Locale != "en" || got.Requested != locale || len(b.Check()) != 1 {
t.Fatal(got, err, b.Check())
}
var pack bytes.Buffer
if err := b.Export(&pack, locale, "common"); err != nil {
t.Fatal(err)
}
if !bytes.Contains(pack.Bytes(), []byte("UNICODE LICENSE")) {
t.Fatal("exported rule data must carry its license")
}
if node, err := exec.LookPath("node"); err == nil {
input, _ := json.Marshal(struct {
Pack json.RawMessage `json:"pack"`
Cases []any `json:"cases"`
}{pack.Bytes(), []any{map[string]any{"key": "fallback", "params": map[string]any{}, "want": "Source"}}})
cmd := exec.Command(node, "browser/conformance.mjs")
cmd.Stdin = bytes.NewReader(input)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s: %v", out, err)
}
}
})
}
}
func TestStrictLoadingAndExplicitOverlay(t *testing.T) {
raw := `{"version":1,"locale":"en","namespace":"common","messages":{"x":{"value":{"literal":true,"text":"${HOME}"}}}}`
fs := fstest.MapFS{"en.json": {Data: []byte(raw)}}
loaded, err := i18n.LoadFS(fs, "en.json")
if err != nil {
t.Fatal(err)
}
if loaded[0].Messages["x"].Value.Text != "${HOME}" {
t.Fatal("environment interpolation")
}
for _, bad := range []string{strings.Replace(raw, `"version":1`, `"version":1,"version":1`, 1), strings.Replace(raw, `"version":1`, `"unknown":1`, 1), strings.Replace(raw, `{"value":{"literal":true,"text":"${HOME}"}}`, `{}`, 1), strings.Replace(raw, `"literal":true`, `"literal":true,"unknown":true`, 1), raw + ` {}`, strings.Repeat("[", 34) + "0" + strings.Repeat("]", 34)} {
var d i18n.Document
if err := i18n.DecodeJSON([]byte(bad), &d); err == nil {
t.Fatal("invalid JSON accepted", bad)
}
}
c := i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}
if _, err := i18n.New(c, loaded[0], loaded[0]); err == nil {
t.Fatal("duplicate accepted")
}
override := i18n.Document{Version: 1, Locale: "EN", Namespace: "common", Messages: map[string]i18n.Message{"x": {Value: i18n.Pattern{Text: "replacement"}}}}
merged, err := i18n.Overlay(loaded[0], override)
if err != nil {
t.Fatal(err)
}
b, err := i18n.New(c, merged)
if err != nil {
t.Fatal(err)
}
got, _ := b.Render("en", i18n.Ref{Namespace: "common", Key: "x"}, nil)
if got.Text != "replacement" || loaded[0].Messages["x"].Value.Text != "${HOME}" {
t.Fatal(got)
}
}
func TestBrowserConformance(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("Node.js is required for cross-runtime conformance")
}
b := makeBundle(t)
type sample struct {
Key string `json:"key"`
Params map[string]any `json:"params"`
Want string `json:"want"`
}
cases := map[string][]sample{
"en": {{"count", map[string]any{"count": 1}, "one:1"}, {"count", map[string]any{"count": 2}, "other:2"}, {"count", map[string]any{"count": "9223372036854775807"}, "other:9223372036854775807"}, {"decimal", map[string]any{"count": 1.5}, "other:1.5"}, {"decimal", map[string]any{"count": 1e-16}, "other:1e-16"}, {"choice", map[string]any{"type": "admin", "count": 2}, "admin:2"}},
"ru": {{"count", map[string]any{"count": 21}, "one:21"}, {"count", map[string]any{"count": 22}, "few:22"}, {"count", map[string]any{"count": 11}, "many:11"}, {"count", map[string]any{"count": 10000021}, "one:10000021"}, {"decimal", map[string]any{"count": 1.25}, "other:1.25"}},
"ar": {{"count", map[string]any{"count": 0}, "zero:0"}, {"count", map[string]any{"count": 2}, "two:2"}, {"count", map[string]any{"count": 3}, "few:3"}, {"count", map[string]any{"count": 11}, "many:11"}, {"count", map[string]any{"count": 100}, "other:100"}},
"zh-Hans": {{"count", map[string]any{"count": 1}, "other:1"}, {"greeting", map[string]any{"first_name": "{count}"}, "Hello {count} {literal}"}},
"fr": {{"count", map[string]any{"count": 0}, "one:0"}, {"count", map[string]any{"count": 2}, "other:2"}, {"count", map[string]any{"count": 1000000}, "many:1000000"}},
}
for locale, samples := range cases {
for _, s := range samples {
params := map[string]any{}
for k, v := range s.Params {
params[k] = v
}
if v, ok := params["count"].(string); ok {
params["count"] = json.Number(v)
}
got, err := b.Render(locale, i18n.Ref{Namespace: "common", Key: s.Key}, params)
if err != nil || got.Text != s.Want {
t.Fatalf("Go %s %s: %+v %v", locale, s.Key, got, err)
}
}
var pack bytes.Buffer
if err := b.Export(&pack, locale, "common"); err != nil {
t.Fatal(err)
}
input, _ := json.Marshal(struct {
Pack json.RawMessage `json:"pack"`
Cases []sample `json:"cases"`
}{pack.Bytes(), samples})
cmd := exec.Command(node, "browser/conformance.mjs")
cmd.Stdin = bytes.NewReader(input)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("browser %s: %s %v", locale, output, err)
}
}
var ts bytes.Buffer
if err := b.TypeScript(&ts, "common"); err != nil {
t.Fatal(err)
}
if !strings.Contains(ts.String(), "bigint") || !strings.Contains(ts.String(), `"first_name": string`) {
t.Fatal(ts.String())
}
if err := b.Export(&bytes.Buffer{}, "en"); err == nil {
t.Fatal("export requires explicit namespaces")
}
}
func TestTypeScriptConsumer(t *testing.T) {
tsc, err := exec.LookPath("tsc")
if err != nil {
t.Skip("TypeScript compiler is required for generated type conformance")
}
dir := t.TempDir()
var types bytes.Buffer
if err := makeBundle(t).TypeScript(&types, "common"); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "messages.generated.d.mts"), types.Bytes(), 0600); err != nil {
t.Fatal(err)
}
for target, source := range map[string]string{"runtime.d.mts": "browser/runtime.d.mts", "consumer.mts": "browser/testdata/consumer.mts"} {
data, err := os.ReadFile(source)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, target), data, 0600); err != nil {
t.Fatal(err)
}
}
cmd := exec.Command(tsc, "--noEmit", "--strict", "--target", "ES2022", "--module", "NodeNext", "--moduleResolution", "NodeNext", "consumer.mts")
cmd.Dir = dir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("TypeScript consumer: %s %v", output, err)
}
}
func FuzzDecodeJSON(f *testing.F) {
f.Add([]byte(`{"version":1,"locale":"en","namespace":"x","messages":{"x":{"value":{"text":"hello"}}}}`))
f.Fuzz(func(t *testing.T, data []byte) {
var d i18n.Document
if i18n.DecodeJSON(data, &d) == nil {
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}, d)
if err == nil {
for _, doc := range b.Documents("") {
if !reflect.DeepEqual(doc, b.Documents(doc.Locale)[0]) {
t.Fatal("unstable resource")
}
}
}
}
})
}
func BenchmarkRender(b *testing.B) {
catalog := makeBundle(b)
params := map[string]any{"count": 22}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := catalog.Render("ru", i18n.Ref{Namespace: "common", Key: "count"}, params); err != nil {
b.Fatal(err)
}
}
}

41
i18n/cldr/LICENSE Normal file
View File

@@ -0,0 +1,41 @@
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 2015-2024 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
SPDX-License-Identifier: Unicode-3.0

19
i18n/cldr/README.md Normal file
View File

@@ -0,0 +1,19 @@
# Pinned cardinal plural data
`plurals.json` is copied without modification from Unicode CLDR JSON **46.1.0**:
https://github.com/unicode-org/cldr-json/blob/46.1.0/cldr-json/cldr-core/supplemental/plurals.json
The upstream data identifies CLDR version 46 / Unicode 16.0.0. See the accompanying
Unicode license. No data is downloaded at application startup or during generation.
Both Go and the browser evaluate these same rules. Browser packs include only the
rules for the exported language and its fallback chain, together with the Unicode
license. They do not depend on the
browser's built-in `Intl.PluralRules` data version. Language-tag matching is a
separate responsibility provided by `golang.org/x/text/language`.
When updating this data, update `PluralRulesVersion`, preserve the license, and run
the upstream-sample and Go/browser conformance tests. This version supports cardinal
rules for ordinary numbers; compact-number operands `c`/`e` are zero. Ordinal and
compact-number formatting are not part of the v1 message format.

933
i18n/cldr/plurals.json Normal file
View File

@@ -0,0 +1,933 @@
{
"supplemental": {
"version": {
"_unicodeVersion": "16.0.0",
"_cldrVersion": "46"
},
"plurals-type-cardinal": {
"af": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ak": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"am": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"an": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ar": {
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-few": "n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
"pluralRule-count-many": "n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
"pluralRule-count-other": " @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ars": {
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-few": "n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
"pluralRule-count-many": "n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
"pluralRule-count-other": " @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"as": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"asa": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ast": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"az": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bal": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"be": {
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, …",
"pluralRule-count-few": "n % 10 = 2..4 and n % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 2.0, 3.0, 4.0, 22.0, 23.0, 24.0, 32.0, 33.0, 102.0, 1002.0, …",
"pluralRule-count-many": "n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, …"
},
"bem": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bez": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bg": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bho": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"blo": {
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bm": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bn": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bo": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"br": {
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11,71,91 @integer 1, 21, 31, 41, 51, 61, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 81.0, 101.0, 1001.0, …",
"pluralRule-count-two": "n % 10 = 2 and n % 100 != 12,72,92 @integer 2, 22, 32, 42, 52, 62, 82, 102, 1002, … @decimal 2.0, 22.0, 32.0, 42.0, 52.0, 62.0, 82.0, 102.0, 1002.0, …",
"pluralRule-count-few": "n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99 @integer 3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003, … @decimal 3.0, 4.0, 9.0, 23.0, 24.0, 29.0, 33.0, 34.0, 103.0, 1003.0, …",
"pluralRule-count-many": "n != 0 and n % 1000000 = 0 @integer 1000000, … @decimal 1000000.0, 1000000.00, 1000000.000, 1000000.0000, …",
"pluralRule-count-other": " @integer 0, 5~8, 10~20, 100, 1000, 10000, 100000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, …"
},
"brx": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"bs": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ca": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"ce": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ceb": {
"pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …"
},
"cgg": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"chr": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ckb": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"cs": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-few": "i = 2..4 and v = 0 @integer 2~4",
"pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …"
},
"csw": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"cy": {
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-few": "n = 3 @integer 3 @decimal 3.0, 3.00, 3.000, 3.0000",
"pluralRule-count-many": "n = 6 @integer 6 @decimal 6.0, 6.00, 6.000, 6.0000",
"pluralRule-count-other": " @integer 4, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"da": {
"pluralRule-count-one": "n = 1 or t != 0 and i = 0,1 @integer 1 @decimal 0.1~1.6",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 2.0~3.4, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"de": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"doi": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"dsb": {
"pluralRule-count-one": "v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-two": "v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, …",
"pluralRule-count-few": "v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"dv": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"dz": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ee": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"el": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"en": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"eo": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"es": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"et": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"eu": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"fa": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ff": {
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"fi": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"fil": {
"pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …"
},
"fo": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"fr": {
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"fur": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"fy": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ga": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-few": "n = 3..6 @integer 3~6 @decimal 3.0, 4.0, 5.0, 6.0, 3.00, 4.00, 5.00, 6.00, 3.000, 4.000, 5.000, 6.000, 3.0000, 4.0000, 5.0000, 6.0000",
"pluralRule-count-many": "n = 7..10 @integer 7~10 @decimal 7.0, 8.0, 9.0, 10.0, 7.00, 8.00, 9.00, 10.00, 7.000, 8.000, 9.000, 10.000, 7.0000, 8.0000, 9.0000, 10.0000",
"pluralRule-count-other": " @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"gd": {
"pluralRule-count-one": "n = 1,11 @integer 1, 11 @decimal 1.0, 11.0, 1.00, 11.00, 1.000, 11.000, 1.0000",
"pluralRule-count-two": "n = 2,12 @integer 2, 12 @decimal 2.0, 12.0, 2.00, 12.00, 2.000, 12.000, 2.0000",
"pluralRule-count-few": "n = 3..10,13..19 @integer 3~10, 13~19 @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 3.00",
"pluralRule-count-other": " @integer 0, 20~34, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"gl": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"gsw": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"gu": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"guw": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"gv": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 @integer 1, 11, 21, 31, 41, 51, 61, 71, 101, 1001, …",
"pluralRule-count-two": "v = 0 and i % 10 = 2 @integer 2, 12, 22, 32, 42, 52, 62, 72, 102, 1002, …",
"pluralRule-count-few": "v = 0 and i % 100 = 0,20,40,60,80 @integer 0, 20, 40, 60, 80, 100, 120, 140, 1000, 10000, 100000, 1000000, …",
"pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 3~10, 13~19, 23, 103, 1003, …"
},
"ha": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"haw": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"he": {
"pluralRule-count-one": "i = 1 and v = 0 or i = 0 and v != 0 @integer 1 @decimal 0.0~0.9, 0.00~0.05",
"pluralRule-count-two": "i = 2 and v = 0 @integer 2",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.0~2.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"hi": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"hnj": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"hr": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"hsb": {
"pluralRule-count-one": "v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-two": "v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, …",
"pluralRule-count-few": "v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"hu": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"hy": {
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ia": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"id": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ig": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ii": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"io": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"is": {
"pluralRule-count-one": "t = 0 and i % 10 = 1 and i % 100 != 11 or t % 10 = 1 and t % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~0.9, 1.2~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"it": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"iu": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ja": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"jbo": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"jgo": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"jmc": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"jv": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"jw": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ka": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kab": {
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kaj": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kcg": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kde": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kea": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kk": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kkj": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kl": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"km": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kn": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ko": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ks": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ksb": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ksh": {
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ku": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"kw": {
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n % 100 = 2,22,42,62,82 or n % 1000 = 0 and n % 100000 = 1000..20000,40000,60000,80000 or n != 0 and n % 1000000 = 100000 @integer 2, 22, 42, 62, 82, 102, 122, 142, 1000, 10000, 100000, … @decimal 2.0, 22.0, 42.0, 62.0, 82.0, 102.0, 122.0, 142.0, 1000.0, 10000.0, 100000.0, …",
"pluralRule-count-few": "n % 100 = 3,23,43,63,83 @integer 3, 23, 43, 63, 83, 103, 123, 143, 1003, … @decimal 3.0, 23.0, 43.0, 63.0, 83.0, 103.0, 123.0, 143.0, 1003.0, …",
"pluralRule-count-many": "n != 1 and n % 100 = 1,21,41,61,81 @integer 21, 41, 61, 81, 101, 121, 141, 161, 1001, … @decimal 21.0, 41.0, 61.0, 81.0, 101.0, 121.0, 141.0, 161.0, 1001.0, …",
"pluralRule-count-other": " @integer 4~19, 100, 1004, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.1, 1000000.0, …"
},
"ky": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lag": {
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
"pluralRule-count-one": "i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lb": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lg": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lij": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lkt": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lld": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"ln": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lo": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lt": {
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11..19 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, …",
"pluralRule-count-few": "n % 10 = 2..9 and n % 100 != 11..19 @integer 2~9, 22~29, 102, 1002, … @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, …",
"pluralRule-count-many": "f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, …",
"pluralRule-count-other": " @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"lv": {
"pluralRule-count-zero": "n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-other": " @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, …"
},
"mas": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"mg": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"mgo": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"mk": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~1.0, 1.2~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ml": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"mn": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"mo": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-few": "v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, …"
},
"mr": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ms": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"mt": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-few": "n = 0 or n % 100 = 3..10 @integer 0, 3~10, 103~109, 1003, … @decimal 0.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
"pluralRule-count-many": "n % 100 = 11..19 @integer 11~19, 111~117, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
"pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"my": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nah": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"naq": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nb": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nd": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ne": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nl": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nn": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nnh": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"no": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nqo": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nr": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nso": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ny": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"nyn": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"om": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"or": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"os": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"osa": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"pa": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"pap": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"pcm": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"pl": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …",
"pluralRule-count-many": "v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …",
"pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"prg": {
"pluralRule-count-zero": "n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-other": " @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, …"
},
"ps": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"pt": {
"pluralRule-count-one": "i = 0..1 @integer 0, 1 @decimal 0.0~1.5",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"pt-PT": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"rm": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ro": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-few": "v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, …"
},
"rof": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ru": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …",
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …",
"pluralRule-count-many": "v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …",
"pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"rwk": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sah": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"saq": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sat": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sc": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"scn": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"sd": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sdh": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"se": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"seh": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ses": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sg": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sh": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"shi": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-few": "n = 2..10 @integer 2~10 @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00",
"pluralRule-count-other": " @integer 11~26, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~1.9, 2.1~2.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"si": {
"pluralRule-count-one": "n = 0,1 or i = 0 and f = 1 @integer 0, 1 @decimal 0.0, 0.1, 1.0, 0.00, 0.01, 1.00, 0.000, 0.001, 1.000, 0.0000, 0.0001, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.2~0.9, 1.1~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sk": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-few": "i = 2..4 and v = 0 @integer 2~4",
"pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …"
},
"sl": {
"pluralRule-count-one": "v = 0 and i % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, …",
"pluralRule-count-two": "v = 0 and i % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, …",
"pluralRule-count-few": "v = 0 and i % 100 = 3..4 or v != 0 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …"
},
"sma": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"smi": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"smj": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"smn": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sms": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sn": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"so": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sq": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sr": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ss": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ssy": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"st": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"su": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sv": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"sw": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"syr": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ta": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"te": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"teo": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"th": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ti": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"tig": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"tk": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"tl": {
"pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
"pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …"
},
"tn": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"to": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"tpi": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"tr": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ts": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"tzm": {
"pluralRule-count-one": "n = 0..1 or n = 11..99 @integer 0, 1, 11~24 @decimal 0.0, 1.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0",
"pluralRule-count-other": " @integer 2~10, 100~106, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ug": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"uk": {
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …",
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …",
"pluralRule-count-many": "v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …",
"pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"und": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ur": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"uz": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"ve": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"vec": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
},
"vi": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"vo": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"vun": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"wa": {
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"wae": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"wo": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"xh": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"xog": {
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"yi": {
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"yo": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"yue": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"zh": {
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
},
"zu": {
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
}
}
}
}

151
i18n/errori18n/errors.go Normal file
View File

@@ -0,0 +1,151 @@
// Package errori18n adapts public errors to independent i18n dictionaries.
package errori18n
import (
"context"
"fmt"
"reflect"
"sort"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/i18n"
)
type Localizer struct {
bundle *i18n.Bundle
report func(error)
}
// New borrows an immutable Bundle. report is optional, must be concurrency-safe,
// and receives diagnostics without argument values or internal error causes.
func New(bundle *i18n.Bundle, report func(error)) *Localizer {
return &Localizer{bundle: bundle, report: report}
}
func kind(t string) i18n.Kind {
switch t {
case "string":
return i18n.String
case "bool":
return i18n.Boolean
case "int", "int32", "int64":
return i18n.Integer
case "float64":
return i18n.Number
}
return ""
}
// Bind returns a detached catalog, assigning references only to entries which
// do not already have one. Different catalogs can bind to different namespaces.
func Bind(c apierrors.Catalog, namespace string) apierrors.Catalog {
c = c.Clone()
for key, d := range c.Definitions {
if d.Translation == nil {
d.Translation = &apierrors.Translation{Namespace: namespace, Key: key}
c.Definitions[key] = d
}
}
return c
}
// Source exports only explicitly referenced public messages. Codes, statuses,
// examples and causes are not included. Fixed legacy braces remain literal.
func Source(c apierrors.Catalog, locale string) ([]i18n.Document, error) {
r, err := apierrors.NewRegistry(c)
if err != nil {
return nil, err
}
groups := map[string]i18n.Document{}
for _, d := range r.Catalog().Definitions {
if d.Translation == nil {
continue
}
ref := d.Translation
doc, ok := groups[ref.Namespace]
if !ok {
doc = i18n.Document{Version: i18n.Version, Locale: locale, Namespace: ref.Namespace, Messages: map[string]i18n.Message{}}
}
m := i18n.Message{Params: map[string]i18n.Kind{}, Value: i18n.Pattern{Text: d.Message, Literal: d.Params == nil}}
for _, p := range d.Params {
m.Params[p.Name] = kind(p.Type)
}
if prior, exists := doc.Messages[ref.Key]; exists && !reflect.DeepEqual(prior, m) {
return nil, fmt.Errorf("errori18n: conflicting source messages for one translation reference")
}
doc.Messages[ref.Key] = m
groups[ref.Namespace] = doc
}
names := []string{}
for ns := range groups {
names = append(names, ns)
}
sort.Strings(names)
docs := []i18n.Document{}
for _, ns := range names {
docs = append(docs, groups[ns])
}
_, err = i18n.New(i18n.Config{SourceLocale: locale, DefaultLocale: locale, Locales: []string{locale}}, docs...)
return docs, err
}
// Validate can be used during startup or generation to check explicit references
// and type compatibility. A runtime mismatch always preserves the original error.
func (l *Localizer) Validate(c apierrors.Catalog) error {
r, err := apierrors.NewRegistry(c)
if err != nil {
return err
}
for _, d := range r.Catalog().Definitions {
if d.Translation != nil {
if err := l.check(d); err != nil {
return err
}
}
}
return nil
}
func (l *Localizer) check(d apierrors.Definition) error {
ref := d.Translation
if l == nil || l.bundle == nil {
return &i18n.Diagnostic{Kind: "missing", Problem: "error localizer has no bundle"}
}
schema, ok := l.bundle.Schema(i18n.Ref{Namespace: ref.Namespace, Key: ref.Key})
if !ok {
return &i18n.Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Problem: "error translation reference is not defined"}
}
expected := map[string]i18n.Kind{}
for _, p := range d.Params {
expected[p.Name] = kind(p.Type)
}
if !reflect.DeepEqual(schema, expected) {
return &i18n.Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Problem: "error parameter schema differs from message schema"}
}
return nil
}
func (l *Localizer) Localize(ctx context.Context, registry *apierrors.Registry, failure *apierrors.APIError) (string, bool) {
if l == nil || failure == nil {
return "", false
}
d, ok := registry.Definition(failure.Code)
if !ok || d.Translation == nil {
return "", false
}
if err := l.check(d); err != nil {
if l.report != nil {
l.report(err)
}
return "", false
}
result, err := l.bundle.RenderContext(ctx, i18n.Ref{Namespace: d.Translation.Namespace, Key: d.Translation.Key}, failure.Parameters())
if err != nil {
if l.report != nil {
l.report(err)
}
return "", false
}
if result.Fallback && l.report != nil {
l.report(&i18n.Diagnostic{Kind: "fallback", Namespace: d.Translation.Namespace, Key: d.Translation.Key, Locale: result.Requested, Problem: "used a fallback dictionary"})
}
return result.Text, true
}

View File

@@ -0,0 +1,54 @@
package errori18n_test
import (
"context"
"errors"
"testing"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/i18n"
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
)
func TestCatalogAdapterIsExplicitAndDetached(t *testing.T) {
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"A": {Status: 409, Code: 7, Message: "{value}", Params: []apierrors.Parameter{{Name: "value", Type: "int64"}}}, "LITERAL": {Code: 8, Message: "${HOME}"}}}
bound := errori18n.Bind(c, "errors")
if c.Definitions["A"].Translation != nil {
t.Fatal("mutated source")
}
source, err := errori18n.Source(bound, "en")
if err != nil {
t.Fatal(err)
}
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}, source...)
if err != nil {
t.Fatal(err)
}
l := errori18n.New(b, nil)
if err := l.Validate(bound); err != nil {
t.Fatal(err)
}
r := apierrors.MustRegistry(bound)
bound.Definitions["A"].Translation.Namespace = "changed"
metadata, _ := r.Definition("A")
metadata.Translation.Namespace = "changed"
metadata.Params[0].Type = "bool"
private := errors.New("private")
failure := r.Resolve(apierrors.ByCode("A").WithParams(map[string]any{"value": int64(9223372036854775807)}).WithCause(private))
message, ok := l.Localize(context.Background(), r, failure)
if !ok || message != "9223372036854775807" || failure.Status != 409 || !errors.Is(failure, private) {
t.Fatal(message, ok, failure)
}
if _, ok := l.Localize(context.Background(), apierrors.MustRegistry(c), failure); ok {
t.Fatal("implicit translation")
}
missing := errori18n.New(nil, nil)
if _, ok := missing.Localize(context.Background(), r, failure); ok {
t.Fatal("missing bundle translated")
}
args := failure.Parameters()
args["value"] = int64(0)
if failure.Parameters()["value"] != int64(9223372036854775807) {
t.Fatal("mutable parameters")
}
}

123
i18n/export.go Normal file
View File

@@ -0,0 +1,123 @@
package i18n
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"golang.org/x/text/language"
)
// Pack is a self-contained browser artifact for one selected language. Its
// documents include the entire configured fallback chain and parameter schemas.
type Pack struct {
Version int `json:"version"`
Locale string `json:"locale"`
Config Config `json:"config"`
Documents []Document `json:"documents"`
RuleVersion string `json:"ruleVersion"`
PluralRules map[string]map[string]string `json:"pluralRules"`
License string `json:"license"`
}
func (b *Bundle) namespaces(names []string) error {
if b == nil {
return fmt.Errorf("i18n: bundle required")
}
if len(names) == 0 {
return fmt.Errorf("i18n: export requires explicit namespaces")
}
known := map[string]bool{}
for ref := range b.entries {
known[ref.Namespace] = true
}
seen := map[string]bool{}
for _, ns := range names {
if !known[ns] || seen[ns] {
return fmt.Errorf("i18n: export namespace is unknown or duplicated: %s", ns)
}
seen[ns] = true
}
return nil
}
func (b *Bundle) Export(w io.Writer, locale string, namespaces ...string) error {
if w == nil {
return fmt.Errorf("i18n: writer required")
}
if err := b.namespaces(namespaces); err != nil {
return err
}
l, err := canonical(locale)
if err != nil {
return err
}
if _, ok := b.tags[l]; !ok {
return fmt.Errorf("i18n: export locale must be enabled")
}
docs := []Document{}
for _, candidate := range b.chain(l) {
docs = append(docs, b.Documents(candidate, namespaces...)...)
}
// Source is always needed by clients even if absent from an explicit chain.
rules := map[string]map[string]string{}
for _, locale := range b.chain(l) {
rules[locale] = rulesFor(language.MustParse(locale)).text
}
pack := Pack{Version: Version, Locale: l, Config: b.Config(), Documents: docs, RuleVersion: PluralRulesVersion, PluralRules: rules, License: unicodeLicense}
var buffer bytes.Buffer
enc := json.NewEncoder(&buffer)
enc.SetIndent("", " ")
if err := enc.Encode(pack); err != nil {
return err
}
if buffer.Len() > 4<<20 {
return fmt.Errorf("i18n: exported pack exceeds 4 MiB; split namespaces")
}
_, err = io.Copy(w, &buffer)
return err
}
// TypeScript emits nested namespace/key types, so keys containing punctuation
// never collide through concatenation or Go-specific identifier conversion.
func (b *Bundle) TypeScript(w io.Writer, namespaces ...string) error {
if w == nil {
return fmt.Errorf("i18n: writer required")
}
if err := b.namespaces(namespaces); err != nil {
return err
}
var out strings.Builder
out.WriteString("// Generated by go-omni i18n types. Do not edit.\nexport interface MessageParams {\n")
for _, doc := range b.Documents(b.config.SourceLocale, namespaces...) {
fmt.Fprintf(&out, " %s: {\n", strconv.Quote(doc.Namespace))
for _, key := range sortedKeys(doc.Messages) {
m := doc.Messages[key]
if len(m.Params) == 0 {
fmt.Fprintf(&out, " %s: Record<string, never>;\n", strconv.Quote(key))
continue
}
fmt.Fprintf(&out, " %s: {", strconv.Quote(key))
for _, name := range sortedKeys(m.Params) {
typ := "string"
switch m.Params[name] {
case Boolean:
typ = "boolean"
case Integer:
typ = "number | bigint | `${bigint}`"
case Number:
typ = "number"
}
fmt.Fprintf(&out, " %s: %s;", strconv.Quote(name), typ)
}
out.WriteString(" };\n")
}
out.WriteString(" };\n")
}
out.WriteString("}\nexport type Namespace = keyof MessageParams;\nexport type Translate = <N extends Namespace, K extends Extract<keyof MessageParams[N], string>>(namespace: N, key: K, params: Extract<MessageParams[N][K], Record<string, unknown>>) => string;\n")
_, err := io.WriteString(w, out.String())
return err
}

131
i18n/httpi18n/http.go Normal file
View File

@@ -0,0 +1,131 @@
// Package httpi18n negotiates an i18n language for standard HTTP requests.
package httpi18n
import (
"fmt"
"net/http"
"strings"
"git.yuntuo.hk/k2safe/go-omni/i18n"
"golang.org/x/text/language"
)
// Resolver supplies ordered language tags, e.g. from an authenticated preference.
// It must be safe for concurrent requests. It does not receive a mutable Bundle.
// A panic is treated as no preference, preserving request execution.
type Resolver func(*http.Request) []string
type Option func(*Policy) error
type Policy struct {
bundle *i18n.Bundle
resolver Resolver
vary []string
private bool
}
// WithResolver overrides the default Accept-Language resolver. Cache variation
// is explicit. With no varying headers, responses are marked private.
func WithResolver(resolver Resolver, varyHeaders ...string) Option {
return func(p *Policy) error {
if resolver == nil {
return fmt.Errorf("httpi18n: nil resolver")
}
p.resolver = resolver
p.vary = nil
p.private = len(varyHeaders) == 0
for _, h := range varyHeaders {
if !headerName(h) {
return fmt.Errorf("httpi18n: invalid Vary header")
}
p.vary = append(p.vary, http.CanonicalHeaderKey(h))
}
return nil
}
}
func headerName(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c > 127 || !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", c)) {
return false
}
}
return true
}
func New(bundle *i18n.Bundle, options ...Option) (*Policy, error) {
if bundle == nil {
return nil, fmt.Errorf("httpi18n: bundle required")
}
p := &Policy{bundle: bundle, vary: []string{"Accept-Language"}, resolver: func(r *http.Request) []string {
header := r.Header.Get("Accept-Language")
if len(header) > 8192 {
return nil
}
tags, _, err := language.ParseAcceptLanguage(header)
if err != nil {
return nil
}
out := []string{}
for _, tag := range tags {
if len(out) == 64 {
break
}
out = append(out, tag.String())
}
return out
}}
for _, o := range options {
if o == nil {
return nil, fmt.Errorf("httpi18n: nil option")
}
if err := o(p); err != nil {
return nil, err
}
}
return p, nil
}
// Apply installs the negotiated language before application middleware. An
// explicit context preference takes priority and makes the response private.
// It does not claim Content-Language: individual messages may use fallbacks.
func (p *Policy) Apply(w http.ResponseWriter, r *http.Request) *http.Request {
if p == nil {
return r
}
preferences := []string{}
explicit := i18n.Locale(r.Context())
if explicit != "" {
preferences = append(preferences, explicit)
}
preferences = append(preferences, p.resolve(r)...)
for _, h := range p.vary {
addVary(w.Header(), h)
}
if p.private || explicit != "" {
w.Header().Add("Cache-Control", "private")
}
return r.WithContext(i18n.WithLocale(r.Context(), p.bundle.Match(preferences...)))
}
func (p *Policy) resolve(r *http.Request) (preferences []string) {
defer func() {
if recover() != nil {
preferences = nil
}
}()
return p.resolver(r)
}
func addVary(h http.Header, value string) {
for _, line := range h.Values("Vary") {
for _, v := range strings.Split(line, ",") {
if strings.EqualFold(strings.TrimSpace(v), value) || strings.TrimSpace(v) == "*" {
return
}
}
}
h.Add("Vary", value)
}
func (p *Policy) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, p.Apply(w, r)) })
}

View File

@@ -0,0 +1,59 @@
package httpi18n_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/i18n"
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
)
func TestNegotiationAndExplicitResolvers(t *testing.T) {
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "fr", "zh-Hans", "zh-Hant"}})
if err != nil {
t.Fatal(err)
}
p, err := httpi18n.New(b)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct{ header, explicit, want string }{{"fr;q=0.8,en;q=0.1", "", "fr"}, {"fr;q=0,en;q=1", "", "en"}, {"zh-TW", "", "zh-Hant"}, {"zh-CN", "", "zh-Hans"}, {"invalid;garbage", "", "en"}, {strings.Repeat("x", 8193), "", "en"}, {"fr", "en", "en"}} {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Language", test.header)
if test.explicit != "" {
r = r.WithContext(i18n.WithLocale(r.Context(), test.explicit))
}
w := httptest.NewRecorder()
w.Header().Set("Vary", "Origin")
p.Wrap(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
if got := i18n.Locale(r.Context()); got != test.want {
t.Errorf("got %s want %s", got, test.want)
}
})).ServeHTTP(w, r)
if strings.Join(w.Header().Values("Vary"), ",") != "Origin,Accept-Language" {
t.Fatal(w.Header())
}
if test.explicit != "" && !strings.Contains(w.Header().Get("Cache-Control"), "private") {
t.Fatal(w.Header())
}
}
custom, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { return []string{"fr"} }))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
r := custom.Apply(w, httptest.NewRequest("GET", "/", nil))
if i18n.Locale(r.Context()) != "fr" || w.Header().Get("Cache-Control") != "private" {
t.Fatal(r, w.Header())
}
broken, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { panic("preference lookup failed") }))
if err != nil {
t.Fatal(err)
}
r = broken.Apply(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
if i18n.Locale(r.Context()) != "en" {
t.Fatal("resolver panic must preserve default selection")
}
}

122
i18n/i18ntool/extract.go Normal file
View File

@@ -0,0 +1,122 @@
// Package i18ntool provides conservative, syntax-only Go key extraction.
package i18ntool
import (
"go/ast"
"go/parser"
"go/token"
"strconv"
)
type Reference struct {
Namespace string `json:"namespace"`
Key string `json:"key"`
File string `json:"file"`
Line int `json:"line"`
}
type Finding struct {
File string `json:"file"`
Line int `json:"line"`
Problem string `json:"problem"`
}
type Extraction struct {
References []Reference `json:"references"`
Findings []Finding `json:"findings"`
}
// ExtractGo reads literal i18n.Ref expressions, including import aliases. It
// neither executes project code nor claims to resolve dynamic keys or wrappers.
// Render/RenderContext calls without a literal reference are reported for review.
func ExtractGo(filename string, source []byte) (Extraction, error) {
out := Extraction{References: []Reference{}, Findings: []Finding{}}
fs := token.NewFileSet()
file, err := parser.ParseFile(fs, filename, source, 0)
if err != nil {
return out, err
}
aliases := map[string]bool{}
for _, imp := range file.Imports {
path, _ := strconv.Unquote(imp.Path.Value)
if path == "git.yuntuo.hk/k2safe/go-omni/i18n" {
name := "i18n"
if imp.Name != nil {
name = imp.Name.Name
}
if name == "." {
out.Findings = append(out.Findings, Finding{filename, fs.Position(imp.Pos()).Line, "dot import requires manual extraction"})
} else {
aliases[name] = true
}
}
}
if len(aliases) == 0 {
return out, nil
}
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
sel, ok := lit.Type.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Ref" {
return true
}
pkg, ok := sel.X.(*ast.Ident)
if !ok || !aliases[pkg.Name] {
return true
}
values := map[string]string{}
valid := true
for i, el := range lit.Elts {
key := ""
value := el
if kv, ok := el.(*ast.KeyValueExpr); ok {
id, ok := kv.Key.(*ast.Ident)
if !ok {
valid = false
continue
}
key = id.Name
value = kv.Value
} else if i == 0 {
key = "Namespace"
} else if i == 1 {
key = "Key"
} else {
valid = false
}
s, ok := value.(*ast.BasicLit)
if !ok || s.Kind != token.STRING {
valid = false
continue
}
v, e := strconv.Unquote(s.Value)
if e != nil {
valid = false
}
values[key] = v
}
line := fs.Position(lit.Pos()).Line
if valid && values["Namespace"] != "" && values["Key"] != "" {
out.References = append(out.References, Reference{values["Namespace"], values["Key"], filename, line})
} else {
out.Findings = append(out.Findings, Finding{filename, line, "dynamic or incomplete message reference"})
}
return true
})
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Render" && sel.Sel.Name != "RenderContext" || len(call.Args) != 3 {
return true
}
if _, ok := call.Args[1].(*ast.CompositeLit); !ok {
out.Findings = append(out.Findings, Finding{filename, fs.Position(call.Pos()).Line, "nonliteral Render reference; verify manually (syntax-only extraction)"})
}
return true
})
return out, nil
}

View File

@@ -0,0 +1,14 @@
package i18ntool_test
import (
"git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool"
"testing"
)
func TestAliasesLiteralsAndDynamicDiagnostics(t *testing.T) {
source := []byte("package demo\nimport tr \"git.yuntuo.hk/k2safe/go-omni/i18n\"\nfunc f(){ b.Render(\"en\",tr.Ref{Namespace:\"forms\",Key:\"button\"},nil); b.Render(\"en\",dynamic,nil); _=tr.Ref{Namespace:\"forms\",Key:name} }\n")
result, err := i18ntool.ExtractGo("demo.go", source)
if err != nil || len(result.References) != 1 || len(result.Findings) != 2 || result.References[0].Namespace != "forms" {
t.Fatal(result, err)
}
}

143
i18n/load.go Normal file
View File

@@ -0,0 +1,143 @@
package i18n
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/fs"
"unicode/utf8"
)
// DecodeJSON rejects unknown fields, duplicate keys and excessive nesting.
// It never expands environment references or evaluates message text.
func DecodeJSON(data []byte, dst any) error {
if len(data) > 1<<20 || !utf8.Valid(data) {
return fmt.Errorf("i18n: document exceeds 1 MiB or is not UTF-8")
}
scan := json.NewDecoder(bytes.NewReader(data))
scan.UseNumber()
nodes := 0
var walk func(int) error
walk = func(depth int) error {
nodes++
if depth > 32 || nodes > 100000 {
return fmt.Errorf("i18n: JSON nesting or node limit exceeded")
}
t, err := scan.Token()
if err != nil {
return fmt.Errorf("i18n: invalid JSON")
}
d, ok := t.(json.Delim)
if !ok {
return nil
}
switch d {
case '{':
seen := map[string]bool{}
for scan.More() {
k, e := scan.Token()
if e != nil {
return fmt.Errorf("i18n: invalid JSON object")
}
key, ok := k.(string)
if !ok || seen[key] {
return fmt.Errorf("i18n: duplicate JSON key")
}
seen[key] = true
if err := walk(depth + 1); err != nil {
return err
}
}
case '[':
for scan.More() {
if err := walk(depth + 1); err != nil {
return err
}
}
default:
return fmt.Errorf("i18n: invalid JSON delimiter")
}
_, err = scan.Token()
return err
}
if err := walk(0); err != nil {
return err
}
if _, err := scan.Token(); err != io.EOF {
return fmt.Errorf("i18n: expected one JSON value")
}
d := json.NewDecoder(bytes.NewReader(data))
d.UseNumber()
d.DisallowUnknownFields()
if err := d.Decode(dst); err != nil {
return fmt.Errorf("i18n: invalid document schema")
}
return nil
}
// LoadFS reads exactly the explicitly listed files; it never scans directories.
func LoadFS(source fs.FS, paths ...string) ([]Document, error) {
if source == nil || len(paths) > 1024 {
return nil, fmt.Errorf("i18n: filesystem required; at most 1024 files")
}
docs := make([]Document, 0, len(paths))
total := 0
for _, path := range paths {
if !fs.ValidPath(path) {
return nil, fmt.Errorf("i18n: invalid resource path")
}
f, err := source.Open(path)
if err != nil {
return nil, fmt.Errorf("i18n: cannot open %s: %w", path, err)
}
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() {
f.Close()
return nil, fmt.Errorf("i18n: resource must be a regular file: %s", path)
}
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
f.Close()
if err != nil {
return nil, err
}
total += len(data)
if total > 4<<20 {
return nil, fmt.Errorf("i18n: resources exceed 4 MiB")
}
var doc Document
if err := DecodeJSON(data, &doc); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
docs = append(docs, doc)
}
return docs, nil
}
// Overlay replaces whole messages, never individual branches or parameter
// schemas. The returned document is detached and must still pass New validation.
func Overlay(base, override Document) (Document, error) {
l1, e1 := canonical(base.Locale)
l2, e2 := canonical(override.Locale)
if e1 != nil || e2 != nil || l1 != l2 || base.Namespace != override.Namespace || base.Version != Version || override.Version != Version {
return Document{}, fmt.Errorf("i18n: overlay requires matching version, locale and namespace")
}
// A bounded JSON round trip also rejects cyclic Go values before cloning.
data, err := json.Marshal([]Document{base, override})
if err != nil || len(data) > 1<<20 {
return Document{}, fmt.Errorf("i18n: invalid or oversized overlay")
}
var docs []Document
if err := DecodeJSON(data, &docs); err != nil {
return Document{}, err
}
out := docs[0]
out.Locale = l1
if out.Messages == nil {
out.Messages = map[string]Message{}
}
for key, m := range docs[1].Messages {
out.Messages[key] = m
}
return out, nil
}

221
i18n/pattern.go Normal file
View File

@@ -0,0 +1,221 @@
package i18n
import (
"encoding/json"
"math"
"reflect"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"golang.org/x/text/language"
)
var paramName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`)
var integerText = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
type part struct{ text, param string }
type compiled struct {
parts []part
selectKey, pluralKey string
cases map[string]*compiled
}
type budget struct{ nodes, bytes int }
func compile(p Pattern, schema map[string]Kind, used map[string]bool, b *budget, depth int) (*compiled, error) {
b.nodes++
b.bytes += len(p.Text)
if depth > 24 || b.nodes > 100000 || b.bytes > 4<<20 || len(p.Text) > 64<<10 || !utf8.ValidString(p.Text) {
return nil, &Diagnostic{Kind: "resource", Problem: "pattern exceeds limits or contains invalid UTF-8"}
}
c := &compiled{selectKey: p.Select, pluralKey: p.Plural}
if p.Select != "" || p.Plural != "" {
if p.Select != "" && p.Plural != "" || p.Text != "" || p.Literal || len(p.Cases) == 0 || len(p.Cases) > 128 {
return nil, &Diagnostic{Kind: "pattern", Problem: "expected one select/plural branch with cases"}
}
key := p.Select
if key == "" {
key = p.Plural
}
kind, ok := schema[key]
if !ok || p.Plural != "" && kind != Integer && kind != Number || p.Select != "" && kind != String && kind != Boolean {
return nil, &Diagnostic{Kind: "parameter", Param: key, Problem: "branch requires a declared parameter of the appropriate type"}
}
used[key] = true
if _, ok := p.Cases["other"]; !ok {
return nil, &Diagnostic{Kind: "pattern", Problem: "branch requires other"}
}
c.cases = make(map[string]*compiled, len(p.Cases))
for _, name := range sortedKeys(p.Cases) {
if !validID(name) {
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid case name"}
}
if p.Plural != "" && !pluralCase(name) {
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid plural category or exact integer case"}
}
child, err := compile(p.Cases[name], schema, used, b, depth+1)
if err != nil {
return nil, err
}
c.cases[name] = child
}
return c, nil
}
if p.Cases != nil {
return nil, &Diagnostic{Kind: "pattern", Problem: "cases require select or plural"}
}
if p.Literal {
c.parts = []part{{text: p.Text}}
return c, nil
}
var literal strings.Builder
flush := func() {
if literal.Len() > 0 {
c.parts = append(c.parts, part{text: literal.String()})
literal.Reset()
}
}
for i := 0; i < len(p.Text); {
ch := p.Text[i]
if ch != '{' && ch != '}' {
literal.WriteByte(ch)
i++
continue
}
if i+1 < len(p.Text) && p.Text[i+1] == ch {
literal.WriteByte(ch)
i += 2
continue
}
if ch == '}' {
return nil, &Diagnostic{Kind: "pattern", Problem: "unescaped closing brace"}
}
end := strings.IndexByte(p.Text[i+1:], '}')
if end < 0 {
return nil, &Diagnostic{Kind: "pattern", Problem: "unclosed placeholder"}
}
name := p.Text[i+1 : i+1+end]
if _, ok := schema[name]; !ok {
return nil, &Diagnostic{Kind: "parameter", Param: name, Problem: "undeclared placeholder"}
}
used[name] = true
flush()
c.parts = append(c.parts, part{param: name})
i += end + 2
}
flush()
return c, nil
}
func pluralCase(s string) bool {
switch s {
case "zero", "one", "two", "few", "many", "other":
return true
}
if !strings.HasPrefix(s, "=") {
return false
}
n, err := strconv.ParseInt(s[1:], 10, 64)
return err == nil && strconv.FormatInt(n, 10) == s[1:]
}
func scalar(kind Kind, value any) (string, error) {
bad := func() (string, error) {
return "", &Diagnostic{Kind: "parameter", Problem: "wrong type, range, or non-finite value"}
}
if value == nil {
return bad()
}
v := reflect.ValueOf(value)
switch kind {
case String:
if v.Kind() == reflect.String && v.Type() != reflect.TypeOf(json.Number("")) && utf8.ValidString(v.String()) && len(v.String()) <= 64<<10 {
return v.String(), nil
}
case Boolean:
if v.Kind() == reflect.Bool {
return strconv.FormatBool(v.Bool()), nil
}
case Integer:
if n, ok := value.(json.Number); ok {
x, e := n.Int64()
if e == nil {
return strconv.FormatInt(x, 10), nil
}
return bad()
}
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(v.Int(), 10), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if v.Uint() <= math.MaxInt64 {
return strconv.FormatUint(v.Uint(), 10), nil
}
}
case Number:
var n float64
if j, ok := value.(json.Number); ok {
var e error
n, e = j.Float64()
if e != nil {
return bad()
}
} else {
switch v.Kind() {
case reflect.Float32, reflect.Float64:
n = v.Float()
default:
return bad()
}
}
if !math.IsNaN(n) && !math.IsInf(n, 0) {
if n == 0 {
return "0", nil
}
// ECMAScript's plain/scientific thresholds, shared with the browser runtime.
format := byte('f')
if math.Abs(n) < 1e-6 || math.Abs(n) >= 1e21 {
format = 'e'
}
s := strconv.FormatFloat(n, format, -1, 64)
s = strings.ReplaceAll(strings.ReplaceAll(s, "e-0", "e-"), "e+0", "e+")
return s, nil
}
}
return bad()
}
func (c *compiled) render(tag language.Tag, values map[string]string) (string, error) {
if c.cases != nil {
key := values[c.selectKey]
if c.pluralKey != "" {
n := values[c.pluralKey]
key = "=" + n
if _, ok := c.cases[key]; !ok {
var err error
key, err = category(tag, n)
if err != nil {
return "", err
}
}
}
next := c.cases[key]
if next == nil {
next = c.cases["other"]
}
return next.render(tag, values)
}
var out strings.Builder
for _, p := range c.parts {
value := p.text
if p.param != "" {
value = values[p.param]
}
if out.Len()+len(value) > 1<<20 {
return "", &Diagnostic{Kind: "resource", Problem: "rendered message exceeds 1 MiB"}
}
out.WriteString(value)
}
return out.String(), nil
}

196
i18n/plural.go Normal file
View File

@@ -0,0 +1,196 @@
package i18n
import (
_ "embed"
"encoding/json"
"fmt"
"math/big"
"regexp"
"strconv"
"strings"
"golang.org/x/text/language"
)
// PluralRulesVersion pins both runtimes to the same Unicode data. Changing it
// is a presentation compatibility change, independent of message format Version.
const PluralRulesVersion = "cldr-46.1"
//go:embed cldr/plurals.json
var pluralData []byte
//go:embed cldr/LICENSE
var unicodeLicense string
type pluralRange struct{ lo, hi *big.Int }
type pluralCondition struct {
operand byte
modulus *big.Int
negate bool
ranges []pluralRange
}
type pluralRule [][]pluralCondition // OR of AND groups, per the CLDR grammar.
type pluralSet struct {
text map[string]string
rules map[string]pluralRule
}
var pluralConditionSyntax = regexp.MustCompile(`^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$`)
var cardinalRules = loadCardinalRules()
func bigDecimal(s string) *big.Int {
if s == "" {
return new(big.Int)
}
n, ok := new(big.Int).SetString(s, 10)
if !ok {
panic("i18n: invalid embedded plural integer")
}
return n
}
func parsePluralRule(text string) (pluralRule, error) {
var result pluralRule
if len(text) > 2048 {
return nil, fmt.Errorf("plural rule too large")
}
for _, or := range strings.Split(text, " or ") {
var group []pluralCondition
for _, and := range strings.Split(or, " and ") {
parts := pluralConditionSyntax.FindStringSubmatch(strings.TrimSpace(and))
if parts == nil {
return nil, fmt.Errorf("invalid CLDR plural condition")
}
c := pluralCondition{operand: parts[1][0], negate: parts[3] == "!="}
if parts[2] != "" {
c.modulus = bigDecimal(parts[2])
}
for _, r := range strings.Split(parts[4], ",") {
lo, hi, has := strings.Cut(strings.TrimSpace(r), "..")
if !has {
hi = lo
}
low, high := bigDecimal(lo), bigDecimal(hi)
if low.Cmp(high) > 0 {
return nil, fmt.Errorf("reversed plural range")
}
c.ranges = append(c.ranges, pluralRange{low, high})
}
group = append(group, c)
}
result = append(result, group)
}
return result, nil
}
func loadCardinalRules() map[string]pluralSet {
var data struct {
Supplemental struct {
Rules map[string]map[string]string `json:"plurals-type-cardinal"`
} `json:"supplemental"`
}
if err := json.Unmarshal(pluralData, &data); err != nil {
panic("i18n: invalid embedded CLDR data")
}
out := map[string]pluralSet{}
for locale, entries := range data.Supplemental.Rules {
set := pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
for name, raw := range entries {
category := strings.TrimPrefix(name, "pluralRule-count-")
text, _, _ := strings.Cut(raw, "@")
text = strings.TrimSpace(text)
if category == "other" {
continue
}
rule, err := parsePluralRule(text)
if err != nil {
panic("i18n: unsupported embedded CLDR rule for " + locale)
}
set.text[category] = text
set.rules[category] = rule
}
out[strings.ReplaceAll(locale, "_", "-")] = set
}
return out
}
func rulesFor(tag language.Tag) pluralSet {
for {
if rules, ok := cardinalRules[tag.String()]; ok {
return rules
}
if tag == language.Und {
break
}
tag = tag.Parent()
}
return pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
}
// operands expands the canonical numeric string exactly, avoiding any float
// conversion for int64 and any browser rounding of tiny fractional operands.
func operands(text string) (map[byte]*big.Int, bool) {
text = strings.TrimPrefix(text, "-")
mantissa, exponent, has := strings.Cut(text, "e")
shift := 0
if has {
shift, _ = strconv.Atoi(exponent)
}
whole, fraction, _ := strings.Cut(mantissa, ".")
digits := whole + fraction
point := len(whole) + shift
switch {
case point <= 0:
whole = "0"
fraction = strings.Repeat("0", -point) + digits
case point >= len(digits):
whole = digits + strings.Repeat("0", point-len(digits))
fraction = ""
default:
whole = digits[:point]
fraction = digits[point:]
}
tail := strings.TrimRight(fraction, "0")
i, f := bigDecimal(whole), bigDecimal(fraction)
return map[byte]*big.Int{'n': i, 'i': i, 'v': big.NewInt(int64(len(fraction))), 'w': big.NewInt(int64(len(tail))), 'f': f, 't': bigDecimal(tail), 'c': new(big.Int), 'e': new(big.Int)}, f.Sign() != 0
}
func (rule pluralRule) matches(values map[byte]*big.Int, fractional bool) bool {
for _, group := range rule {
all := true
for _, condition := range group {
value := values[condition.operand]
if condition.modulus != nil {
value = new(big.Int).Mod(value, condition.modulus)
}
found := false
if condition.operand != 'n' || !fractional {
for _, r := range condition.ranges {
if value.Cmp(r.lo) >= 0 && value.Cmp(r.hi) <= 0 {
found = true
break
}
}
}
if condition.negate {
found = !found
}
if !found {
all = false
break
}
}
if all {
return true
}
}
return false
}
func category(tag language.Tag, text string) (string, error) {
values, fractional := operands(text)
rules := rulesFor(tag).rules
for _, category := range []string{"zero", "one", "two", "few", "many"} {
if rule, ok := rules[category]; ok && rule.matches(values, fractional) {
return category, nil
}
}
return "other", nil
}

61
i18n/plural_test.go Normal file
View File

@@ -0,0 +1,61 @@
package i18n
import (
"encoding/json"
"strings"
"testing"
)
func TestPinnedCLDRSamples(t *testing.T) {
var data struct {
Supplemental struct {
Rules map[string]map[string]string `json:"plurals-type-cardinal"`
} `json:"supplemental"`
}
if err := json.Unmarshal(pluralData, &data); err != nil {
t.Fatal(err)
}
count := 0
for locale, entries := range data.Supplemental.Rules {
for name, raw := range entries {
want := strings.TrimPrefix(name, "pluralRule-count-")
for _, section := range strings.Split(raw, "@")[1:] {
_, samples, ok := strings.Cut(section, " ")
if !ok {
continue
}
for _, sample := range strings.Split(samples, ",") {
for _, number := range strings.Split(strings.TrimSpace(sample), "~") {
if number == "" || strings.ContainsAny(number, "…ce") {
continue
}
values, fractional := operands(number)
got := "other"
for _, cat := range []string{"zero", "one", "two", "few", "many"} {
if rule, ok := cardinalRules[locale].rules[cat]; ok && rule.matches(values, fractional) {
got = cat
break
}
}
count++
if got != want {
t.Errorf("%s %s: got %s want %s", locale, number, got, want)
}
}
}
}
}
}
if count < 5000 {
t.Fatalf("too few CLDR samples: %d", count)
}
t.Logf("validated %d upstream cardinal samples", count)
}
func TestRuleParserRejectsUnsupportedSyntax(t *testing.T) {
for _, text := range []string{"n = 1; run()", "n % 0 = 1", "n = 5..1", "unknown = 1"} {
if _, err := parsePluralRule(text); err == nil {
t.Fatal(text)
}
}
}

120
i18n/types.go Normal file
View File

@@ -0,0 +1,120 @@
// Package i18n provides immutable, instance-scoped message catalogs. It has no
// dependency on HTTP, application configuration, business errors or storage.
package i18n
import (
"context"
"encoding/json"
"fmt"
)
const Version = 1
// Kind describes portable message arguments, independently of Go identifiers.
type Kind string
const (
String Kind = "string"
Boolean Kind = "boolean"
Integer Kind = "integer" // Signed int64; browser callers may use bigint/string.
Number Kind = "number" // Finite IEEE-754 float64.
)
// Pattern is a text leaf or a select/plural branch. Branches require "other".
// Text uses {name}; {{ and }} escape braces. Literal disables interpolation.
// Plural is cardinal, with optional exact integer cases such as "=0".
type Pattern struct {
Text string `json:"text,omitempty"`
Literal bool `json:"literal,omitempty"`
Select string `json:"select,omitempty"`
Plural string `json:"plural,omitempty"`
Cases map[string]Pattern `json:"cases,omitempty"`
}
type Message struct {
Params map[string]Kind `json:"params,omitempty"`
Value Pattern `json:"value"`
}
func (m *Message) UnmarshalJSON(data []byte) error {
type plain Message
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if value, ok := raw["value"]; !ok || string(value) == "null" {
return fmt.Errorf("i18n: message requires a value")
}
for key := range raw {
if key != "value" && key != "params" {
return fmt.Errorf("i18n: unknown message field")
}
}
var value plain
// Pattern's fields are checked by the strict decoder, including nested cases.
if err := DecodeJSON(data, &value); err != nil {
return err
}
*m = Message(value)
return nil
}
// Document is the versioned interchange format. Source-locale documents own
// parameter schemas; translations inherit them and may repeat identical schemas.
type Document struct {
Version int `json:"version"`
Namespace string `json:"namespace"`
Locale string `json:"locale"`
Messages map[string]Message `json:"messages"`
}
// Config distinguishes source text from the default selection and enabled locales.
// Fallbacks are explicit, ordered edges; cycles are rejected. The default locale
// must be enabled. The source locale can remain a fallback-only language.
type Config struct {
SourceLocale string `json:"sourceLocale"`
DefaultLocale string `json:"defaultLocale"`
Locales []string `json:"locales"`
Fallbacks map[string][]string `json:"fallbacks,omitempty"`
}
type Ref struct{ Namespace, Key string }
// Result reports the actual dictionary language, which may differ from Requested.
type Result struct {
Text string
Requested string
Locale string
Fallback bool
}
// Diagnostic deliberately excludes argument values and message contents.
type Diagnostic struct {
Kind string `json:"kind"`
Namespace string `json:"namespace,omitempty"`
Key string `json:"key,omitempty"`
Locale string `json:"locale,omitempty"`
Param string `json:"param,omitempty"`
Problem string `json:"problem"`
}
func (d *Diagnostic) Error() string {
return fmt.Sprintf("i18n: %s namespace=%q key=%q locale=%q param=%q: %s", d.Kind, d.Namespace, d.Key, d.Locale, d.Param, d.Problem)
}
type localeKey struct{}
// WithLocale records a preference; rendering still matches it against the bundle.
func WithLocale(ctx context.Context, locale string) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, localeKey{}, locale)
}
func Locale(ctx context.Context) string {
if ctx == nil {
return ""
}
value, _ := ctx.Value(localeKey{}).(string)
return value
}

10
omni.go
View File

@@ -6,8 +6,10 @@ import (
"git.yuntuo.hk/k2safe/go-omni/app"
"git.yuntuo.hk/k2safe/go-omni/auth"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/cors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/metrics"
"git.yuntuo.hk/k2safe/go-omni/response"
@@ -27,6 +29,7 @@ type APIError = apierrors.APIError
type Config = config.Config
type TLSConfig = config.TLSConfig
type HTTPConfig = config.HTTPConfig
type CORSConfig = cors.Config
type EndpointsConfig = config.EndpointsConfig
type EndpointConfig = config.EndpointConfig
type HealthEndpointConfig = config.HealthEndpointConfig
@@ -54,6 +57,11 @@ func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
func WithErrors(catalog apierrors.Catalog) Option { return config.WithErrors(catalog) }
func WithLanguagePolicy(policy *httpi18n.Policy) Option { return config.WithLanguagePolicy(policy) }
func WithErrorLocalizer(localizer apierrors.Localizer) Option {
return config.WithErrorLocalizer(localizer)
}
func WithResponse(settings response.Config) Option { return config.WithResponse(settings) }
func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) }
@@ -70,6 +78,8 @@ func WithTransport(name string) Option { return config.WithTransport(name) }
func WithHTTPConfig(settings HTTPConfig) Option { return config.WithHTTPConfig(settings) }
func WithCORS(settings CORSConfig) Option { return config.WithCORS(settings) }
func WithTLS(certFile, keyFile string) Option { return config.WithTLS(certFile, keyFile) }
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }

76
reports/corsAcceptance.md Normal file
View File

@@ -0,0 +1,76 @@
# v0.24 CORS 验收(2026-09-26)
新增通用、可选的跨域策略。没有修改接入项目,没有连接业务数据库或对线上服务器进行压测。
## 回归
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:49 个有测试的包通过,包含实际生成消费者和既有生命周期、错误目录、配置、上传下载等回归。
- 最后补充 IPv4-mapped IPv6 来源序列化后,cors/config/app 的 race 复测通过。
- Go 1.22.12 的 cors/config/app 测试通过;`go vet ./...`、`go build ./...` 通过。
- 最后补充来源序列化后,同三个包在 Go 1.22.12 上复测通过。
- managedService 重生成检查、race、根目录 build 通过;userService race 通过。没有提供外部数据库配置的测试按原规则跳过。
- 实际生成的 CORS 消费者只修改配置,不修改 main、svc、handler;构建/race、重复生成、dry-run、check、业务文件字节保留通过。
- `FuzzCORSHeaders` 运行 30 秒、4 worker,执行 2,877,406 次,通过。输入覆盖来源、方法和预检请求头,不等于穷尽所有协议组合。
- `examples/cors` 使用本地临时监听端口验证 204、401、200、404,通过。
| 范围 | 已验证场景 |
| --- | --- |
| 默认与配置 | 默认关闭保持原 Handler;JSON/YAML/XML 一致;显式环境引用;配置错误定位 server.cors 具体字段及文件/环境名/可用位置,不输出值 |
| 来源 | 精确协议/域名/端口、默认端口规范化、IPv6、恶意后缀、null/路径/CRLF 拒绝;通配来源禁止凭据;重复 Origin 拒绝 |
| 预检 | 204 空响应,拒绝为 403;不进入业务鉴权;方法/头校验、大小与数量限制;普通 OPTIONS 仍进入项目 Handler |
| 实际响应 | 业务鉴权保留;自定义数字响应、401/500/panic、404/405 保留跨域头和原响应;不允许来源仅不授予浏览器读取权限 |
| 标准能力 | 原 Request/Context/Body/ResponseWriter 不替换,Flush 等能力保留;Vary 追加/去重,已有值和通配 Vary 保留 |
| transport | nethttp/native/HTTP2 使用真实本地 TLS;预检、原始上传、流式下载、暴露文件名、404 与关闭;HTTP2 确认协议协商 |
| 隔离 | 输入配置、Option、Config() 返回值的切片变更不改变已构建策略;32 个并发调用者各执行 100 次检查,race 通过 |
| 生成项目 | 未实现 Logic 的 501、404/405、健康检查、OpenAPI 和预检采用同一配置;非法凭据/通配组合在启动前报错 |
## 浏览器
通过 Codex In-app Browser,在两个不同的临时 localhost 页面分别执行 10 项真实 fetch 检查,共 20 项通过。记录见 [corsBrowser.json](corsBrowser.json)。
允许来源能读取正常响应、401、404、下载文件名,Authorization 触发的预检通过;不允许的请求头/方法被浏览器拦截。未列入白名单的页面无法读取私有 API;无凭据通配 API 可读取,带 `credentials: include` 的通配请求被拦截。凭据模式测试使用临时演示请求,未设置或使用真实登录 Cookie。
复现:在框架根目录 `go run ./scripts/corsBrowser`,打开终端输出的两个页面地址并点击 Run checks。脚本只监听 127.0.0.1,接收退出信号或五分钟后关闭。它用于浏览器协议验证,不是部署服务。本次临时监听已停止。
## 性能
Go 1.27.1、darwin/arm64、Apple M2 Max,无 race;每场景三轮、benchtime=300ms,表中取中位数。[原始结果](corsBenchmark.txt)。
| 场景 | 时间/次 | 分配/次 |
| --- | ---: | ---: |
| 关闭 CORS | 3.357 ns | 0 B / 0 allocs |
| 开启,无 Origin | 108.2 ns | 16 B / 1 alloc |
| 精确来源允许,含两个暴露头 | 231.5 ns | 48 B / 3 allocs |
| 来源不允许 | 130.4 ns | 16 B / 1 alloc |
| 允许预检 | 589.7 ns | 176 B / 7 allocs |
| 通配来源,无凭据 | 510.3 ns | 216 B / 5 allocs |
关闭时直接返回原 Handler,表中是基准循环与下游空 Handler 的耗时,不能理解为新增延迟。开启时使用预建请求、复用 Header map 的测试 Writer;不包含网络、TLS 或业务处理,不能换算成实际服务 QPS。精确白名单只查初始化时建立的表;通配模式还会验证传入来源是否合法,因此更慢。请求和响应体不进入本模块内存缓冲。
```sh
go test ./cors -run '^$' -bench BenchmarkCORS -benchmem -benchtime=300ms -count=3
go test ./cors -run '^$' -fuzz '^FuzzCORSHeaders$' -fuzztime=30s -parallel=4
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/apigen -run '^TestGeneratedCORSConsumer$'
```
## 边界
预检按应用策略判断,不探测每条路由。业务访问日志和指标不统计被 CORS 提前结束的预检,可在代理或 App 外层记录。网络解析/TLS/网络过载等进入 App 前的拒绝不由 CORS 改写。
默认关闭,同源反向代理可继续使用原部署方式。只支持 HTTP(S) 精确来源或单一无凭据通配来源,不提供子域通配/正则/null 来源。WebSocket Origin、Private Network Access 和对象存储自身的 CORS 属于其他接口,本轮不包含。
不批准来源不等于阻止服务端执行实际请求,项目的身份认证、权限和 CSRF 策略仍然必要。代理和应用只选择一处负责 CORS,下游不能覆盖许可头或 Vary。详细用法见 [cors 指南](../cors/README.md)。
## 远程版本验收
`v0.24.0-preview` 标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`。空 module 缓存安装远程 CLI,生成项目固定引用远程框架版本,无本地 replace;checksum 为 `h1:tHQSk+XawxJLWnN60so45GoA9An0m1xw5GFU8Ds1NoQ=`。
验收耗时 66.78 秒,70 项检查通过。9 组实际生成消费者执行 race/build;新增 CORS 消费者只改配置即可使用,重复生成/check 保留业务文件。下载模块的 cors/app 全包 race 通过,含三种 transport 的真实 TLS 检查;此前 config/httpc/copyx/logx、初始化/监听失败清理、后台关闭顺序、错误目录继承、上传下载、workspace 等回归继续通过。
版本、检查项及源码哈希见 [corsRemote.json](corsRemote.json)。复现命令:
```sh
python3 scripts/verifyManagedRelease.py --version v0.24.0-preview --report reports/corsRemote.json
```
发布标签不随此验收记录提交移动。

24
reports/corsBenchmark.txt Normal file
View File

@@ -0,0 +1,24 @@
goos: darwin
goarch: arm64
pkg: git.yuntuo.hk/k2safe/go-omni/cors
cpu: Apple M2 Max
BenchmarkCORS/disabled-12 98300086 3.347 ns/op 0 B/op 0 allocs/op
BenchmarkCORS/disabled-12 100000000 3.650 ns/op 0 B/op 0 allocs/op
BenchmarkCORS/disabled-12 105484652 3.357 ns/op 0 B/op 0 allocs/op
BenchmarkCORS/noOrigin-12 3340696 111.6 ns/op 16 B/op 1 allocs/op
BenchmarkCORS/noOrigin-12 3420523 108.2 ns/op 16 B/op 1 allocs/op
BenchmarkCORS/noOrigin-12 3297222 108.2 ns/op 16 B/op 1 allocs/op
BenchmarkCORS/allowed-12 1557544 232.6 ns/op 48 B/op 3 allocs/op
BenchmarkCORS/allowed-12 1552413 230.4 ns/op 48 B/op 3 allocs/op
BenchmarkCORS/allowed-12 1561892 231.5 ns/op 48 B/op 3 allocs/op
BenchmarkCORS/denied-12 2723037 130.5 ns/op 16 B/op 1 allocs/op
BenchmarkCORS/denied-12 2756702 129.4 ns/op 16 B/op 1 allocs/op
BenchmarkCORS/denied-12 2777206 130.4 ns/op 16 B/op 1 allocs/op
BenchmarkCORS/preflight-12 638340 588.4 ns/op 176 B/op 7 allocs/op
BenchmarkCORS/preflight-12 623992 591.8 ns/op 176 B/op 7 allocs/op
BenchmarkCORS/preflight-12 592320 589.7 ns/op 176 B/op 7 allocs/op
BenchmarkCORS/wildcard-12 708910 510.3 ns/op 216 B/op 5 allocs/op
BenchmarkCORS/wildcard-12 739617 506.8 ns/op 216 B/op 5 allocs/op
BenchmarkCORS/wildcard-12 707361 510.6 ns/op 216 B/op 5 allocs/op
PASS
ok git.yuntuo.hk/k2safe/go-omni/cors 8.971s

137
reports/corsBrowser.json Normal file
View File

@@ -0,0 +1,137 @@
{
"browser": "Codex In-app Browser",
"passed": true,
"allowed": {
"allowedPage": true,
"passed": true,
"checks": [
{
"name": "simple GET",
"pass": true,
"status": 200,
"body": "hello"
},
{
"name": "401 readable",
"pass": true,
"status": 401,
"body": "{\"code\":\"LOGIN_REQUIRED\",\"message\":\"login required\",\"requestId\":\"8e1c6e6f5b5188f2409cc4ea8ef00b31\"}\n"
},
{
"name": "authorization preflight",
"pass": true,
"status": 200,
"body": "authenticated"
},
{
"name": "unknown header rejected",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "unknown method rejected",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "404 readable",
"pass": true,
"status": 404,
"body": "{\"code\":\"NOT_FOUND\",\"message\":\"route not found\",\"requestId\":\"6a0acbdabaa5e41d862f921dc2f9fdf3\"}\n"
},
{
"name": "credentials mode allowed",
"pass": true,
"status": 200,
"body": "hello"
},
{
"name": "wildcard without credentials",
"pass": true,
"status": 200,
"body": "public"
},
{
"name": "wildcard credentials blocked",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "exposed download header",
"pass": true,
"status": 200,
"body": "file bytes"
}
]
},
"denied": {
"allowedPage": false,
"passed": true,
"checks": [
{
"name": "simple GET",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "401 readable",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "authorization preflight",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "unknown header rejected",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "unknown method rejected",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "404 readable",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "credentials mode allowed",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "wildcard without credentials",
"pass": true,
"status": 200,
"body": "public"
},
{
"name": "wildcard credentials blocked",
"pass": true,
"blocked": true,
"error": "TypeError"
},
{
"name": "exposed download header",
"pass": true,
"blocked": true,
"error": "TypeError"
}
]
},
"scope": "20 loopback checks; credentials mode exercised without setting or inspecting cookies"
}

205
reports/corsRemote.json Normal file
View File

@@ -0,0 +1,205 @@
{
"passed": true,
"frameworkVersion": "v0.24.0-preview",
"frameworkSum": "h1:tHQSk+XawxJLWnN60so45GoA9An0m1xw5GFU8Ds1NoQ=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 66.78,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "54bb2f746bec30068102a004db1f009f2bef214cb44053b11ae9d99d0dcd09fd",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"scripts/verifyManagedRelease.py": "825b02631bd9186bb9f8f85b799ce5c84e2d19a64b6b80f35c47cb11343afd12",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "70c8db3b7d5ee5e92b2086a5a18db671e5a3b34ccba19c56514422c11c461c38",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "0376fc8ac461571fc028e144a00b1e324ec4f3bb1f6fa1bcf573b86dd32c7167",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2"
}
}

View File

@@ -58,3 +58,14 @@ go run ./examples/developerTools
- 成功状态选项只接受 2xx 子集,不改变 HTTP 重试策略;业务响应内容是否完整仍由项目协议校验。
- 时区数据由运行环境提供;极简镜像可用 `go build -tags timetzdata .` 内置。logx 不强制引入约 450 KB 的时区数据库。
- 本轮未修改接入项目业务代码,未连接业务数据库或使用远程服务器压测。仍需项目升级后的业务验收。
## 远程版本验收
`v0.23.0-preview` 标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,空 module 缓存安装、固定版本消费者和 checksum 核对通过,无本地 replace。验收耗时 57.06 秒,共 66 项检查。
- 下载模块里的 config/httpc/copyx/logx 全包 race 通过,含本轮新增回归。
- 8 组实际生成消费者使用远程框架执行 race,启动/监听失败清理、后台收尾和业务文件保留通过。
- 根目录运行/构建、nethttp/native 信号与端口释放、404/405、上传下载、错误目录和两服务 workspace 通过。
- 生成检查与 `-check-config` 使用同一配置入口,无手写加载补丁。
完整版本、checksum、检查清单及源码哈希见 [developerToolsRemote.json](developerToolsRemote.json)。标签不随验收文档提交移动。

View File

@@ -0,0 +1,191 @@
{
"passed": true,
"frameworkVersion": "v0.23.0-preview",
"frameworkSum": "h1:rbn2/Fxz6Yg+8LNvD+fwWtM/pigmNg0Mb9fAalntUgQ=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 57.06,
"checks": [
"remoteCLIInstall",
"freshModuleCache",
"fixedVersionNoReplace",
"contractChangePreview",
"persistentLogicSignatureFailure",
"businessSourcePreserved",
"fixedConsumerBuild",
"rootGoRun",
"nethttpAndNativeSignals",
"listenerReleased",
"default404And405",
"goOwnedErrorCatalog",
"unwrappedDownloads",
"rawCallback",
"multipart400And413",
"nethttpAndNativeTransfers",
"sharedGoCatalogExport",
"documentOnlyErrorSelection",
"workspaceCheckNoWrites",
"staleCatalogRejected",
"twoIndependentServiceBuilds",
"initializationAndListenFailureCleanup",
"drainBeforeDependencyClose",
"generatedConsumerRaceTests",
"errorParamsAndInheritance",
"manualHooksAndScalars",
"dependencyFatalShutdown",
"fatalProcessExitCode",
"initializationShutdownNotification",
"configuredBuiltinEndpoints",
"projectHealthEnvelope",
"endpointConflictCleanup",
"responseArrayNullableNull",
"primitiveAndDynamicArrays",
"strictRequestPolicies",
"declarativeMultipartBinding",
"backgroundStopDrainResourceOrder",
"backgroundInitAndListenFailureCleanup",
"backgroundTimeoutRetainsResourcesAndExits",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/apigen/templates.go": "d048178fd125d2244c9deccc744923412b8e8c21a890c5fa57bd6b0f632fcde9",
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
"cmd/go-omni/apigen/generate.go": "c04e1c82d054139f7e21d286b9dd5a1f537ca39e2eaf240135e1dc23b8d18652",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"scripts/verifyManagedRelease.py": "41ebe143c06905ca1ec9736cfddee14d1b03fa69f613a098a1996f669de6f3d7",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
"config/config.go": "e51ece1fa824d8b0fc7385e027b4a4408e459ec2c89444fab99e1e6a19e8c1cf",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
"database/database.go": "0376fc8ac461571fc028e144a00b1e324ec4f3bb1f6fa1bcf573b86dd32c7167",
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440"
}
}

75
reports/i18nAcceptance.md Normal file
View File

@@ -0,0 +1,75 @@
# v0.26 通用国际化验收(2026-09-29)
交付范围:独立不可变词典、可选 HTTP/错误适配、CLI 检查/提取/导出、TypeScript 类型和独立浏览器运行时。普通消息、错误源文案、启用语言、用户偏好、资源提供方式均由项目维护;示例不依赖数据库或其他项目。
## 已通过的验证
环境:macOS / Apple M2 Max,Go 1.27.1,Node.js 22.15.1,TypeScript 5.6.3;最低版本另用 Go 1.22.12 核验。TypeScript 编译器通过 npm 临时缓存使用,没有添加项目 npm 依赖。
| 验证 | 结果 |
| --- | --- |
| `go test -race ./...` | 54 个有测试的包通过;未启用集成环境变量的生成消费者单独执行如下 |
| `go vet ./...`、`go build ./...`、`git diff --check` | 通过 |
| `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./cmd/go-omni/apigen -run 'TestI18nGeneratedConsumer\|TestErrorInheritance\|TestErrorParamsConsumer' -count=1 -v` | 3 组真实生成项目生成、重复生成、check 和消费者 race 测试通过 |
| Go 1.22.12:`go test ./i18n/... ./app ./errors ./response ./httpx ./config ./cmd/go-omni ./examples/i18n` | 全部通过 |
| Go 1.22.12:开启集成测试,`go test ./cmd/go-omni/apigen -run TestI18n -count=1 -v` | translation 元数据边界、真实生成消费者通过 |
| `node --test i18n/browser/runtime.test.mjs` | 4 组通过,包含覆盖、并发切换、最新失败/过期请求、坏包与读取上限 |
| `TestPinnedCLDRSamples` | 5,777 个上游普通基数规则样例端点通过;不把省略号和紧凑数字样例计入 |
| `TestBrowserConformance`、`TestSourceLocaleCanBeFallbackOnly` | Go 导出包在 Node 中执行,中文/英文/俄文/阿拉伯文/法文、精确 int64、小数、回退与源语言未启用通过;cmn 别名及私用标签不由浏览器改写 |
| `TestTypeScriptConsumer`(PATH 提供 tsc) | strict / ES2022 / NodeNext 编译通过;缺参、错类型、多余参数、未知键/命名空间必须产生编译诊断 |
| `go test ./i18n -run '^$' -fuzz FuzzDecodeJSON -fuzztime 10s` | 419,121 次执行通过 |
| 示例 CLI、词典 check、示例 HTTP 测试 | 通过;示例中英文输出及中文错误 HTTP 409 / code 400001 保持 |
最后的 TypeScript 类型修正后,重新执行了 `i18n/...`、CLI、示例 race 测试及对应 Go 1.22 验证。核心一致性及类型验证可复现为:
```sh
npm exec --yes --package typescript@5.6.3 -- go test ./i18n \
-run 'TestTypeScriptConsumer|TestPinnedCLDRSamples|TestBrowserConformance|TestSourceLocaleCanBeFallbackOnly' \
-count=1 -v
```
原始日志见 [i18nValidation.txt](i18nValidation.txt)。
## 兼容和隔离覆盖
- 多 Bundle、多 App、并发请求与多个命名空间不共享可变语言状态;构建输入与导出快照修改不影响实例。
- 项目目录和路由独立目录同名错误正确选取各自翻译;固定旧文案的字面花括号保留。
- 翻译仅进入展示层。HTTP 200 业务失败、HTTP 409/401/500、int64 业务码、嵌套响应模板、旧 Messages 校验、RawJSON 错误、认证提前返回、panic、缺参数和缓冲桥接均有回归。
- 参数值不重新解释为模板,内部 cause 不进入响应;Localizer 或偏好 Resolver panic 不把语言故障传播成新的业务执行结果。
- 自定义固定回执由原 Encoder 控制;直接 Text/JSON 等输出不自动改写。自定义 Encoder 可显式采用 PublicMessage。
- 本地/共享幂等重放保留首次响应,即使后续请求语言不同也只执行业务一次。
- 资源严格校验、循环回退、参数契约不一致、缺键诊断、显式覆盖、CLI 输入不被改写、旧 runtime 能力门槛均有测试。
## 浏览器实测
使用独立示例启动本机 `127.0.0.1:8098`,执行了:
1. 输入测试草稿,从英文切为中文:草稿保留,提交次数为 0。
2. 点击提交:次数变为 1。切回英文:草稿保留,次数仍为 1。
3. 停止示例服务后再选择中文:显示加载失败,仍停留在英文,草稿和一次提交保留。
示例服务和临时浏览器页面已关闭。乱序和快速重复选择由浏览器运行时自动测试验证。
## Go 1.22 发布前兼容性修复
初轮全仓 Go 1.22 验收暴露了两处旧问题,发布准备阶段已修复:
- 共享 Go 目录测试工程的 go.mod 从 1.24.0 调整为框架声明支持的 1.22.0,远程验收的同类测试工程同步调整。
- JSON 对象/数组结束定界符读取失败时保留底层错误,外层 MaxBytesReader 的限额错误继续被识别为 HTTP 413。新增对象、数组、嵌套结构的 reader cause 回归,原有 limit=0/1/4 测试保留。
最初发现问题的日志仍保留在 i18nValidation.txt 中,便于追溯;发布前的最终结果单独记录在 i18nReleaseValidation.txt 中。
## 发布前最终验收
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:54 个有测试的包通过,包含实际生成消费者;PATH 提供 TypeScript 5.6.3,跨端和类型测试实际执行。
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./...`:全仓通过,先前两处失败已消除。
- `go vet ./...`、`go build ./...`、浏览器运行时测试、发布脚本语法检查、`git diff --check`:通过。
- `python3 scripts/verifyManagedProject.py --report reports/i18nManagedLocal.json`:真实生成项目运行、构建、重复生成、nethttp/native 信号退出、上传下载与故障清理通过;临时进程已清理。[机器记录](i18nManagedLocal.json)。
完整最终输出见 [i18nReleaseValidation.txt](i18nReleaseValidation.txt)。发布后再从空 module 缓存安装固定标签,验证没有本地 replace;远程报告独立提交,不移动发布标签。
## 性能与发布边界
`BenchmarkRender` 对俄文基数复数、22 参数的单条消息,当前机器一次测量为 3,753 ns/op、1,040 B/op、27 allocs/op;这只是开发机样本,不代表 SLA 或与旧实现的性能对比。
本次按 Go module 标签 `v0.26.0-preview` 发布;浏览器运行时随 module 分发,没有单独发布 npm 包。CLDR 46.1.0 数据随源码和导出包携带许可,启动不下载规则。v1 支持普通文本、命名参数、select 和基数复数;不是完整 ICU MessageFormat,不自动处理金额、日期、时区、RTL 布局或业务字段翻译。

View File

@@ -0,0 +1,61 @@
{
"passed": true,
"mode": "localSource",
"durationSeconds": 11.57,
"checks": [
"rootGoRun",
"rootGoBuild",
"stableRegeneration",
"checkMainOwnership",
"contractChangePreview",
"logicSignatureCheckPersists",
"businessSourcePreserved",
"fixedConsumerBuild",
"nethttpSIGINT",
"nethttpSIGTERM",
"nativeSIGINT",
"nativeSIGTERM",
"listenerReleased",
"temporaryProcessesRemoved",
"nethttpGeneratedTransfers",
"nativeGeneratedTransfers",
"malformedMultipart400",
"goOwnedErrors",
"default404And405"
],
"sourceHashes": {
"cmd/go-omni/apigen/generate.go": "2305b168701c4564f83d680fc62e6f5a3589f4a3d7e78e66d82a6328bf0a6a89",
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
"cmd/go-omni/apigen/write.go": "7dcdac99760eaa16ec4f729998a2c29c0bf84eefbda229bd5c2d850dd4fc0646",
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
"config/config.go": "f8007526a9ebbbc781b2e27e37a49ec768deb568d56846a6ccc62b059a012fd0",
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
"cmd/go-omni/scaffold/templates/api-config.go.tmpl": "7638869e0bf47f33b3d0b6f9f64d1413a6beb0cdb986f6d65ec68bf477bfd2e7",
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1"
}
}

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