Compare commits

..

8 Commits

144 changed files with 10904 additions and 156 deletions

View File

@@ -1,5 +1,40 @@
# 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 客户端、生命周期和请求策略返回字段级安全诊断,配置加载器保留文件/变量/位置与原始错误链。
- httpc.JSON 新增 WithSuccessStatuses,单次调用可限制允许的 2xx,默认兼容,拒绝响应继续关闭和有界回收。
- copyx.Check[S,T] / Copier.Check 预检嵌套类型与现有规则;修正空集合隐藏类型不匹配,动态接口和 converter 的具体行为继续运行时验证。
- logx.Config.TimeZone 绑定 Logger 实例,JSON/text/子 Logger/标准日志适配一致;不修改 time.Local 或业务时间属性。
- 无新映射文件、强制模块或第三方依赖;更新 README、各包指南和 developerTools 可运行示例。错误文本更精确,空集合中的确定类型错误现在会被拒绝。
## v0.22.0-preview(2026-09-26)
- 文件显式环境引用与备用值,字符串组合/转义,安全有界展开和类型绑定;变量值原样插入、不递归处理。

View File

@@ -141,6 +141,20 @@ config "etc/config.yaml":7:9 env "DB_PORT" field "database.port": expected a val
文件最多 1 MiB;单个环境值/展开字段最多 1 MiB,展开字符串合计/变量缓存分别最多 4 MiB;解析/展开限制 64 层、100000 个节点与引用计数。拒绝无效 UTF-8、多文档、重复字段、未知字段、非普通文件;YAML 不接受 alias/merge/custom tag,XML 不接受 DTD/外部实体。不执行 shell,不自动加载 `.env`。
## 框架模块的字段诊断
v0.23 起 MySQL(含连接池/TLS)、通用数据库池、日志(含轮转/时区)、HTTP 客户端、退出阶段和请求策略返回字段级安全原因。沿用项目已有的 `config.AtField("database", err)` / `config.AtField("server", err)`,自动组合嵌套字段,不需业务重复校验基础参数。
例如文件中 `database.port: "${DB_PORT}"` 的引用值为 70000:
```text
config "etc/config.yaml":3:9 env "DB_PORT" field "database.port": must be between 1 and 65535
```
日志级别非法指向 `server.log.level`;池参数指向 `database.pool.maxIdleConns` / `pingTimeout`。框架只公开已定义的安全原因,第三方/项目普通 error 仍显示 validation failed 并保留 errors.Is/As 原因链;业务使用 Invalid/AtField 提供公开原因。不要解析错误字符串;使用 `*config.Error` 字段。未提供字段的位置可能不可用,字段与原因仍保留。
生成项目现有 Load 和 `-check-config` 自动获得这些诊断。日志可在 `server.log.timeZone` 配置 IANA 名称,见 [实例时区](logx/README.md#实例时区)。
## 可选解析器与测试来源
内置格式无需项目手写加载器。需要额外格式时在已有 NewLoader 注册 Decoder,后续仍自动经过同一引用/绑定/校验流程:
@@ -175,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)。

25
COPY.md
View File

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

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.22.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.22.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.22.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,15 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.22.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)。
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
@@ -31,29 +39,51 @@ 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) |
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持独立时区、轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
| 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;`InTx` 的 `TxError` 提供阶段、已知结果与原因链 | [通用数据库](database/README.md)、[事务失败示例](examples/transactions/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) |
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
## 浏览器跨域 CORS
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
```yaml
server:
cors:
enabled: true
allowedOrigins: [https://admin.example.com, https://merchant.example.com]
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
allowedHeaders: [Authorization, Content-Type]
exposedHeaders: [Content-Disposition, X-Request-ID]
allowCredentials: false
maxAge: 10m
```
不需要修改 main、Logic 或 `.api`。框架在鉴权前处理合法预检;实际请求仍鉴权,401/404/405/500 保持项目响应格式并附带允许来源的跨域头。没有 Origin 的服务端回调正常处理。配置只决定浏览器读取权限,不替代登录、验签或 CSRF 防护。
手写项目用 `omni.WithCORS(omni.CORSConfig{...})`,独立 HTTP 服务用 `cors.New(...).Wrap(handler)`;代理已经处理 CORS 时保持关闭。完整 [默认值与边界](cors/README.md)、[可运行示例](examples/cors/README.md)。
## 带认证的出站 HTTP
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
@@ -126,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.22.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
```
@@ -283,7 +313,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.22.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -509,6 +539,28 @@ if err := copier.Copy(&view, input); err != nil { return err }
自定义函数的源/目标类型由 Go 泛型确定。深拷贝时返回原指针、原 map、子切片或嵌套旧引用都会报错;字符串/整数原值可以返回。差异规则、nil 策略、字段覆盖、资源限制和原 mapping 移除步骤见 [copyx 指南](COPY.md),完整类型及可运行代码见 [examples/copying](examples/copying/main.go)。工具使用运行时检查,类型对需测试;深拷贝不适用于连接、锁等资源。
### 减少接入代码:四个常用入口
```yaml
server:
log:
timeZone: Asia/Shanghai
```
时区由 Logger 实例管理,不修改全局 `time.Local`。非法日志级别、数据库端口/连接池等配置会指出 `server.log.level`、`database.port` 等字段和原因,不打印输入值。详见 [配置诊断](CONFIG.md#框架模块的字段诊断)、[日志时区](logx/README.md#实例时区)。
```go
// 本次请求只接受 HTTP 200;默认仍接受所有 2xx。
err := client.JSON(ctx, http.MethodGet, url, nil, &result,
httpc.WithSuccessStatuses(http.StatusOK),
)
// CI/启动时检查类型,无需构造样本;仍使用 Copy 的同一组 Option。
err = copyx.Check[Request, BusinessInput](options...)
```
状态不符合要求返回 `*httpc.StatusError`,不会写入 result,响应体仍由框架关闭。Check 不执行 converter;动态 interface、具体 nil/转换失败和深拷贝引用检查有明确边界,见 [类型预检](COPY.md#类型预检)。完整可运行代码见 [developerTools](examples/developerTools/README.md)。
### 数据库、存储及其他模块
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
@@ -587,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.22.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,99 @@
# v0.22.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 包装和类型检查。
- 配置诊断定位 database.port、database.pool.maxIdleConns、server.log.level/timeZone 等具体字段,保留文件、环境变量、行列和安全原因;凭据不进入公开提示,原始原因可 errors.Is/As。
- JSON 可传 `httpc.WithSuccessStatuses(200)`,默认仍接受全部 2xx;不匹配时不改目标,继续负责有界读取、关闭和连接回收,不添加新的重试规则。
- `copyx.Check[S,T](options...)` 或已有 Copier.Check 复用同一组 Go 选项,无需样本和映射文件;实际拷贝不再因集合为空而漏过确定的类型不匹配。动态 interface、nil 值、converter 失败及深拷贝共享引用仍有运行时检查。
- `server.log.timeZone: Asia/Shanghai` 配置实例时区;JSON/text/子日志一致,业务 time.Time 属性保持原样。极简镜像可用 `go build -tags timetzdata .` 内置时区数据,库不强制增加体积。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.23.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview
go-omni api generate
go-omni api generate --check
go run . -config etc/config.yaml -check-config
```
现有调用接口保留;配置错误文本现在更精确,业务应通过类型/字段识别错误。空集合里确定的类型错误会提前暴露,需要显式 converter 或调整实际类型,不应靠空值跳过校验。预检通过不代表 converter 对所有业务值必定成功。当前模板最低版本仍是 v0.22,本轮新增 runtime 能力需使用 v0.23。
[完整示例](examples/developerTools/README.md)、[配置](CONFIG.md)、[JSON 选项](httpc/README.md)、[类型预检](COPY.md#类型预检)、[日志时区](logx/README.md#实例时区)。验收与性能记录见 [本轮验收](reports/developerToolsAcceptance.md)。仍为 preview。
48 个测试包全量 race、Go 1.22、vet/build、实际生成消费者及约 392 万次 copyx fuzz 通过。空缓存安装远程版本后,66 项发布检查通过,包括下载模块的 config/httpc/copyx/logx race;耗时 57.06 秒,无本地 replace。标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,见 [远程验收](reports/developerToolsRemote.json)。
---
# v0.22.0-preview(历史版本)
配置文件明确决定每个值的来源,项目只维护 Config、Default()、Validate() 和文件。
@@ -19,6 +114,8 @@ go run . -config etc/config.yaml -check-config
[完整规则与迁移](CONFIG.md)、[可运行示例](examples/configuration/README.md)、[场景与性能验收](reports/explicitConfigurationAcceptance.md) 已同步。48 个测试包全量 race、实际生成消费者、Go 1.22、vet/build 和约 28 万次引用 fuzz 通过;小配置固定值/带引用加载约 25 µs,属于本机启动阶段基准。当前仍为 preview。
空缓存远程 CLI 安装、下载模块 config/httpc race、8 组实际生成消费者与 `-check-config` 验收通过,无本地 replace。标签固定在 `d598b2ebee8cde36859021e882a6bc1e0bb4fd4c`;见 [远程验收记录](reports/explicitConfigurationRemote.json)。
---
# v0.21.0-preview(历史版本)

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.22.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.22.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.22.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.22.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

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

View File

@@ -91,3 +91,23 @@ func TestConfigurationCheckProcess(t *testing.T) {
t.Fatal(err, string(out))
}
}
func TestGeneratedModuleFieldDiagnostic(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
t.Setenv("EXAMPLE_LOG_LEVEL", "invalid-private-level")
if err := os.WriteFile(path, []byte("server:\n log:\n level: '${EXAMPLE_LOG_LEVEL}'\n"), 0600); err != nil {
t.Fatal(err)
}
err := run(context.Background(), path)
var d *omniConfig.Error
if !errors.As(err, &d) || d.Field != "server.log.level" || d.Line != 3 || d.Env != "EXAMPLE_LOG_LEVEL" || !strings.Contains(d.Reason, "debug") || strings.Contains(err.Error(), "invalid-private-level") {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("server:\n log:\n timeZone: Asia/Shanghai\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(path)
if err != nil || cfg.Server.Log.TimeZone != "Asia/Shanghai" {
t.Fatal(err)
}
}

View File

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

View File

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

@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"strings"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// Error is a value-free diagnostic. Cause may contain private parser details;
@@ -56,6 +58,10 @@ func validationError(err error) *Error {
out.cause = err
return &out
}
var nested *configdiag.Error
if errors.As(err, &nested) {
return &Error{Field: nested.Field, Reason: nested.Reason, cause: err}
}
var c *ConfigError
if errors.As(err, &c) {
return &Error{Field: c.Field, Reason: c.Message, cause: err}

View File

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

View File

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

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

192
copyx/check.go Normal file
View File

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

192
copyx/check_test.go Normal file
View File

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

View File

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

82
cors/README.md Normal file
View File

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

144
cors/config.go Normal file
View File

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

187
cors/cors.go Normal file
View File

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

303
cors/cors_test.go Normal file
View File

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

View File

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

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

View File

@@ -10,6 +10,8 @@ import (
"fmt"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
type Config struct {
@@ -43,10 +45,10 @@ func (c Config) PoolConfig() PoolConfig {
func (c Config) Validate() error {
if strings.TrimSpace(c.Driver) == "" {
return errors.New("database driver is required")
return configdiag.New("driver", "is required")
}
if strings.TrimSpace(c.DSN) == "" {
return errors.New("database DSN is required")
return configdiag.New("dsn", "is required")
}
return c.PoolConfig().Validate()
}
@@ -105,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

@@ -70,3 +70,7 @@ func (c *ServiceContext) DatabaseOptions() []mysqladapter.Option {
`WithDriverConfig(func(*mysql.Config) error)` 支持高级拨号、驱动参数等,配置在回调后克隆;这是项目明确选择的底层扩展。`NewConnector` 返回标准 driver.Connector,供已有基础设施使用;它不接受启动检查选项。任意驱动仍可用核心 `database.OpenConnector`。
错误有安全摘要,errors.Is/As 保留 TLS/驱动/项目检查的原因。配置验证不读证书、不连接数据库;Open 才读取证书、创建池并握手。不要自行打印原始配置或展开可能包含内部信息的 driver 字段给用户。
## 字段级配置错误
Validate 返回独立字段与安全原因,例如 port(1–65535)、readTimeout、pool.maxIdleConns、tls.keyFile。项目调用 `config.AtField("database", err)` 后,统一加载器补齐 database 前缀、文件、环境名和行列,无需重复写端口/连接池校验。TLS 成对配置会指出缺失的那个字段。连接失败、驱动和证书文件的内部原因仍通过 errors.Is/As 保留,不把凭据放进公开提示。见 [配置诊断](../../CONFIG.md#框架模块的字段诊断)。

View File

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

View File

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

126
database/transaction.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.22.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.22.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

@@ -85,7 +85,20 @@ err := client.JSON(ctx, "POST", endpoint, input, &output,
- Host、Content-Length、Transfer-Encoding、Trailer 属于请求目标/分帧控制,请用标准 Request 配合 Do;JSON 选项中不允许伪装成普通头。
- `WithMaxResponseBytes` 是本次 JSON 的正数限额,不修改 Client 默认值,不改变 Do 的流式语义。
JSON 自动关闭响应体。仅 2xx 解码;空成功响应(例如 204)保留 output 原值。非空响应使用标准 json.Unmarshal,拒绝多个 JSON 文档,类型错误保留 Go 的错误信息与行为。
`WithSuccessStatuses(200)` 限制本次调用只接受 200;可传多个,例如 `(200, 201)`。不传时保持全部 2xx。列表必须非空且每项位于 200–299,重复项允许;后一个状态选项替换前一个列表。构造选项时快照,之后修改原 slice 不影响请求,并发复用不串配置。
```go
err := client.JSON(ctx, http.MethodGet, endpoint, nil, &result,
httpc.WithHeader("Authorization", credential),
httpc.WithSuccessStatuses(http.StatusOK),
)
var status *httpc.StatusError
if errors.As(err, &status) { /* status.StatusCode 是实际收到的状态 */ }
```
未允许的状态(包括 201/204)返回 StatusError,不解码、不改变 output;仍关闭响应体、按原有时间/大小预算回收连接,不读取无限错误正文。状态选项不会增加重试,原有 Do 重试规则保留。它只限制 HTTP 状态,不保证响应非空或业务字段完整,业务仍校验自身协议。严格状态、关闭与连接复用见 [示例](../examples/developerTools/README.md)。
JSON 自动关闭响应体。默认仅 2xx 解码;指定成功状态列表时只解码列表内状态;空成功响应(例如 204)保留 output 原值。非空响应使用标准 json.Unmarshal,拒绝多个 JSON 文档,类型错误保留 Go 的错误信息与行为。
`output == nil` 按限额流式丢弃成功响应,不把整个 body 读进内存,也不检查 JSON 语法。响应限额按实际读取的 body 计算;标准库自动 gzip 解压后仍受上限约束。input 会先 json.Marshal 到内存,大文件上传不适合用 JSON helper。

View File

@@ -2,7 +2,6 @@ package httpc
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
@@ -10,6 +9,8 @@ import (
"strconv"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// ProxyMode controls the owned transport's proxy selection. Empty preserves
@@ -66,17 +67,23 @@ func normalizeConfig(cfg Config) (Config, error) {
{"responseHeaderTimeout", cfg.ResponseHeaderTimeout}, {"expectContinueTimeout", cfg.ExpectContinueTimeout}, {"idleConnTimeout", cfg.IdleConnTimeout},
} {
if field.value < 0 {
return Config{}, fmt.Errorf("httpc: %s must not be negative", field.name)
return Config{}, configdiag.New(field.name, "must be nonnegative")
}
}
if cfg.MaxRetries < 0 || cfg.MaxRetries > 5 {
return Config{}, errors.New("httpc: maxRetries must be 0..5")
return Config{}, configdiag.New("maxRetries", "must be between 0 and 5")
}
if cfg.MaxConnsPerHost < 0 || cfg.MaxConnsPerHost > 65536 || cfg.MaxIdleConnsPerHost < 0 || cfg.MaxIdleConnsPerHost > 65536 || cfg.MaxIdleConns < 0 || cfg.MaxIdleConns > 131072 {
return Config{}, errors.New("httpc: invalid connection pool limits")
for _, f := range []struct {
name string
value, max int
}{{"maxConnsPerHost", cfg.MaxConnsPerHost, 65536}, {"maxIdleConnsPerHost", cfg.MaxIdleConnsPerHost, 65536}, {"maxIdleConns", cfg.MaxIdleConns, 131072}} {
if f.value < 0 || f.value > f.max {
return Config{}, configdiag.New(f.name, "must be between 0 and "+strconv.Itoa(f.max))
}
}
if cfg.MaxResponseBytes < 0 || cfg.MaxResponseBytes > maxResponseBytes {
return Config{}, errors.New("httpc: maxResponseBytes must be 0..1 GiB")
return Config{}, configdiag.New("maxResponseBytes", "must be between 0 and 1 GiB")
}
if _, err := proxyFunction(cfg.Proxy); err != nil {
return Config{}, err
@@ -86,11 +93,11 @@ func normalizeConfig(cfg Config) (Config, error) {
switch value.Kind() {
case reflect.Pointer, reflect.Map, reflect.Func, reflect.Slice, reflect.Interface, reflect.Chan:
if value.IsNil() {
return Config{}, errors.New("httpc: nil Transport")
return Config{}, configdiag.New("transport", "must not be a typed nil")
}
}
if cfg.hasTransportOptions() {
return Config{}, errors.New("httpc: Transport cannot be combined with proxy, dial, TLS or connection pool options; configure the borrowed transport directly")
return Config{}, configdiag.New("transport", "cannot be combined with proxy, dial, TLS or connection pool options; configure the borrowed transport directly")
}
}
if cfg.Timeout == 0 {
@@ -103,7 +110,7 @@ func normalizeConfig(cfg Config) (Config, error) {
cfg.MaxBackoff = 2 * time.Second
}
if cfg.Backoff > cfg.MaxBackoff {
return Config{}, errors.New("httpc: backoff exceeds maxBackoff")
return Config{}, configdiag.New("backoff", "must not exceed maxBackoff")
}
if cfg.MaxConnsPerHost == 0 {
cfg.MaxConnsPerHost = 64
@@ -124,7 +131,7 @@ func (c Config) hasTransportOptions() bool {
}
func proxyFunction(c ProxyConfig) (func(*http.Request) (*url.URL, error), error) {
if c.Mode != ProxyURL && c.URL != "" {
return nil, errors.New("httpc: proxy URL requires mode url")
return nil, configdiag.New("proxy.mode", "must be url when a URL is configured")
}
switch c.Mode {
case "":
@@ -136,27 +143,27 @@ func proxyFunction(c ProxyConfig) (func(*http.Request) (*url.URL, error), error)
case ProxyURL:
u, err := url.Parse(c.URL)
if err != nil || u == nil || u.Hostname() == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
return nil, errors.New("httpc: proxy URL must be an absolute proxy origin without path, query or fragment")
return nil, configdiag.New("proxy.url", "must be an absolute proxy origin without path, query or fragment")
}
switch u.Scheme {
case "http", "https", "socks5", "socks5h":
default:
return nil, errors.New("httpc: proxy scheme must be http, https, socks5 or socks5h")
return nil, configdiag.New("proxy.url", "scheme must be http, https, socks5 or socks5h")
}
if strings.HasSuffix(u.Host, ":") {
return nil, errors.New("httpc: proxy port must be 1..65535")
return nil, configdiag.New("proxy.url", "port must be between 1 and 65535")
}
if port := u.Port(); port != "" {
n, e := strconv.Atoi(port)
if e != nil || n < 1 || n > 65535 {
return nil, errors.New("httpc: proxy port must be 1..65535")
return nil, configdiag.New("proxy.url", "port must be between 1 and 65535")
}
}
u.Path = ""
u.RawPath = ""
return http.ProxyURL(u), nil
default:
return nil, errors.New("httpc: proxy mode must be environment, direct or url")
return nil, configdiag.New("proxy.mode", "must be environment, direct or url")
}
}
func ownedTransport(cfg Config) (*http.Transport, error) {

View File

@@ -11,7 +11,7 @@ import (
)
// JSON bounds response memory, closes the body, and decodes only 2xx responses.
// Optional headers/limits belong to this call and never alter the client.
// Optional headers/limits/success statuses belong to this call and never alter the client.
// output=nil discards a bounded stream; it does not buffer or validate JSON.
// Empty successful bodies leave output unchanged. StatusError contains no body.
func (c *Client) JSON(ctx context.Context, method, url string, input, output any, options ...RequestOption) error {
@@ -57,7 +57,7 @@ func (c *Client) JSON(ctx context.Context, method, url string, input, output any
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
if !settings.accepts(response.StatusCode) {
drainStatusBody(response, settings.maxResponseBytes)
return &StatusError{StatusCode: response.StatusCode}
}

View File

@@ -9,6 +9,8 @@ import (
type requestOptions struct {
headers http.Header
maxResponseBytes int64
successStatuses [2]uint64
restrictStatus bool
}
// RequestOption applies only to one JSON call. Returned options can be reused
@@ -97,3 +99,41 @@ func checkHeader(name string, values []string) error {
}
return nil
}
// WithSuccessStatuses restricts this JSON call to the listed 2xx statuses.
// Omission keeps all 2xx successful. Empty/out-of-range lists are rejected before
// sending a request. Options snapshot their input and can be reused concurrently.
// The policy does not add retries or validate application-specific JSON fields.
func WithSuccessStatuses(statuses ...int) RequestOption {
var accepted [2]uint64
var err error
if len(statuses) == 0 {
err = errors.New("httpc: at least one success status is required")
}
for _, status := range statuses {
if status < 200 || status >= 300 {
err = errors.New("httpc: success statuses must be between 200 and 299")
break
}
bit := status - 200
accepted[bit/64] |= uint64(1) << uint(bit%64)
}
return func(o *requestOptions) error {
if err != nil {
return err
}
o.successStatuses = accepted
o.restrictStatus = true
return nil
}
}
func (o requestOptions) accepts(status int) bool {
if status < 200 || status >= 300 {
return false
}
if !o.restrictStatus {
return true
}
bit := status - 200
return o.successStatuses[bit/64]&(uint64(1)<<uint(bit%64)) != 0
}

174
httpc/status_test.go Normal file
View File

@@ -0,0 +1,174 @@
package httpc
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestJSONSuccessStatuses(t *testing.T) {
for _, status := range []int{200, 201, 204, 264, 299, 400} {
t.Run(strconv.Itoa(status), func(t *testing.T) {
var calls atomic.Int32
body := &trackedBody{Reader: strings.NewReader(`{"value":2}`)}
c := testClient(t, Config{MaxRetries: 5, Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{StatusCode: status, Body: body, ContentLength: 11, Header: make(http.Header), Request: r}, nil
})})
out := struct {
Value int `json:"value"`
}{Value: 1}
err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, &out, WithSuccessStatuses(200))
if status == 200 {
if err != nil || out.Value != 2 {
t.Fatal(err, out)
}
} else {
var rejected *StatusError
if !errors.As(err, &rejected) || rejected.StatusCode != status || out.Value != 1 {
t.Fatal(err, out)
}
if strings.Contains(err.Error(), "value") {
t.Fatal("status error exposed body")
}
}
if !body.closed || calls.Load() != 1 {
t.Fatal("not closed or unexpected retry", calls.Load())
}
// Unconfigured calls continue to accept every 2xx.
c2 := testClient(t, Config{Transport: benchmarkTransport{`{}`, status}})
err = c2.JSON(context.Background(), "GET", "http://example.invalid", nil, nil)
if (err == nil) != (status >= 200 && status < 300) {
t.Fatal(err)
}
if status >= 200 && status < 300 {
if err := c2.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, WithSuccessStatuses(status, status)); err != nil {
t.Fatal(err)
}
}
})
}
var calls atomic.Int32
c := testClient(t, Config{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
calls.Add(1)
return benchmarkTransport{`{}`, 200}.RoundTrip(r)
})})
for _, option := range []RequestOption{WithSuccessStatuses(), WithSuccessStatuses(199), WithSuccessStatuses(300), WithSuccessStatuses(-1), WithSuccessStatuses(200, 500)} {
if err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, option); err == nil {
t.Fatal("invalid policy accepted")
}
}
if calls.Load() != 0 {
t.Fatal("invalid policy reached transport")
}
}
func TestJSONSuccessStatusConcurrentOptions(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n, _ := strconv.Atoi(r.URL.Query().Get("status"))
w.WriteHeader(n)
if n != 204 {
io.WriteString(w, `{}`)
}
}))
defer srv.Close()
c := testClient(t, Config{})
statuses := []int{200, 201}
option := WithSuccessStatuses(statuses...)
statuses[0] = 204
var wg sync.WaitGroup
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
status := 200 + i%2
err := c.JSON(context.Background(), "GET", fmt.Sprintf("%s?status=%d", srv.URL, status), nil, nil, option)
if err != nil {
t.Error(err)
}
err = c.JSON(context.Background(), "GET", srv.URL+"?status=204", nil, nil, option)
var rejected *StatusError
if !errors.As(err, &rejected) || rejected.StatusCode != 204 {
t.Error(err)
}
}(i)
}
wg.Wait()
// Last explicit policy wins; another call's policy must not leak.
if err := c.JSON(context.Background(), "GET", srv.URL+"?status=204", nil, nil, option, WithSuccessStatuses(204)); err != nil {
t.Fatal(err)
}
if err := c.JSON(context.Background(), "GET", srv.URL+"?status=204", nil, nil); err != nil {
t.Fatal(err)
}
}
func TestJSONRejectedSuccessBodyRecovery(t *testing.T) {
var connections atomic.Int32
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
if r.URL.Path == "/stream" {
w.(http.Flusher).Flush()
<-r.Context().Done()
return
}
io.WriteString(w, "private response")
}))
srv.Config.ConnState = func(_ net.Conn, s http.ConnState) {
if s == http.StateNew {
connections.Add(1)
}
}
srv.Start()
defer srv.Close()
c := testClient(t, Config{Timeout: time.Second})
for i := 0; i < 5; i++ {
err := c.JSON(context.Background(), "GET", srv.URL, nil, nil, WithSuccessStatuses(200))
var status *StatusError
if !errors.As(err, &status) || status.StatusCode != 201 {
t.Fatal(err)
}
}
if connections.Load() != 1 {
t.Fatal("small rejected bodies lost keep-alive", connections.Load())
}
start := time.Now()
err := c.JSON(context.Background(), "GET", srv.URL+"/stream", nil, nil, WithSuccessStatuses(200))
var status *StatusError
if !errors.As(err, &status) || status.StatusCode != 201 || time.Since(start) > 500*time.Millisecond {
t.Fatal("waited for rejected unbounded body", err)
}
if err := c.JSON(context.Background(), "GET", srv.URL, nil, nil, WithSuccessStatuses(201), WithMaxResponseBytes(4)); !errors.Is(err, ErrResponseTooLarge) {
t.Fatal("accepted body bypassed size limit", err)
}
}
func BenchmarkJSONSuccessStatus(b *testing.B) {
for _, restrict := range []bool{false, true} {
b.Run(strconv.FormatBool(restrict), func(b *testing.B) {
c, _ := New(Config{Transport: benchmarkTransport{`{"id":1}`, 200}, DisableLogging: true})
defer c.Close()
var options []RequestOption
if restrict {
options = []RequestOption{WithSuccessStatuses(200)}
}
var out struct {
ID int `json:"id"`
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, &out, options...); err != nil {
b.Fatal(err)
}
}
})
}
}

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

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