Compare commits
8 Commits
v0.22.0-pr
...
v0.26.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| d1a747c36f | |||
| 6a95cad184 | |||
| 7a177744ab | |||
| 8eddef9cc8 | |||
| 800974cfff | |||
| 9fade3bedb | |||
| 3bbc6f60d7 | |||
| 69585dab61 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -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)
|
||||
|
||||
- 文件显式环境引用与备用值,字符串组合/转义,安全有界展开和类型绑定;变量值原样插入、不递归处理。
|
||||
|
||||
22
CONFIG.md
22
CONFIG.md
@@ -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
25
COPY.md
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 通用业务开发指南(v0.22.0-preview)
|
||||
# 通用业务开发指南(v0.26.0-preview)
|
||||
|
||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 和提前返回的中间件一起验收。
|
||||
|
||||
68
README.md
68
README.md
@@ -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)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
99
RELEASE.md
99
RELEASE.md
@@ -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(历史版本)
|
||||
|
||||
@@ -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,运行时与生成器不再通过它固定响应结构。
|
||||
|
||||
18
app/app.go
18
app/app.go
@@ -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
187
app/cors_test.go
Normal 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
241
app/i18n_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func localizedCatalog(namespace string, status int) apierrors.Catalog {
|
||||
return errori18n.Bind(apierrors.Catalog{DefaultStatus: status, Definitions: map[string]apierrors.Definition{
|
||||
"SAME": {Code: int64(9223372036854775807), Message: namespace + ":{value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}},
|
||||
"INTERNAL_ERROR": {Status: 500, Code: int64(500), Message: "internal"},
|
||||
"FIXED": {Status: 401, Code: int64(401), Message: "fixed {literal}"},
|
||||
}}, namespace)
|
||||
}
|
||||
func localizedOptions(t *testing.T, catalogs ...apierrors.Catalog) (*httpi18n.Policy, *errori18n.Localizer) {
|
||||
t.Helper()
|
||||
docs := []i18n.Document{}
|
||||
for _, catalog := range catalogs {
|
||||
source, err := errori18n.Source(catalog, "en")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs = append(docs, source...)
|
||||
for _, d := range source {
|
||||
docs = append(docs, i18n.Document{Version: 1, Namespace: d.Namespace, Locale: "zh-Hans", Messages: map[string]i18n.Message{
|
||||
"SAME": {Value: i18n.Pattern{Text: d.Namespace + "译文:{value}"}}, "FIXED": {Value: i18n.Pattern{Text: "需要认证"}}, "INTERNAL_ERROR": {Value: i18n.Pattern{Text: "系统错误"}},
|
||||
}})
|
||||
}
|
||||
}
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, docs...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := errori18n.New(b, nil)
|
||||
for _, c := range catalogs {
|
||||
if err := l.Validate(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, l
|
||||
}
|
||||
|
||||
func TestI18nRouteScopesEarlyFailuresAndReplay(t *testing.T) {
|
||||
global, local := localizedCatalog("app", 200), localizedCatalog("route", 409)
|
||||
policy, localizer := localizedOptions(t, global, local)
|
||||
format := response.Config{CodeType: "int64", SuccessCode: int64(0), ErrorCode: int64(999), Body: map[string]any{"error": map[string]any{"reason": "${message}", "id": "${code}"}, "ok": "${ok}", "data": "${data}"}, DataMode: "always", Messages: map[string]string{"FIXED": "fixed {literal}"}}
|
||||
app := omni.New(omni.WithErrors(global), omni.WithResponse(format), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
|
||||
defer app.Close()
|
||||
private := errors.New("private-value")
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
switch c.Header("X-Early") {
|
||||
case "auth":
|
||||
return apierrors.ByCode("FIXED")
|
||||
case "panic":
|
||||
panic(private)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
handler := func(c *httpx.Context) error {
|
||||
if c.Header("X-Params") == "missing" {
|
||||
return apierrors.ByCode("SAME")
|
||||
}
|
||||
return apierrors.ByCode("SAME").WithParams(map[string]any{"value": "{literal}"}).WithCause(private)
|
||||
}
|
||||
app.GET("/global", handler)
|
||||
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
|
||||
})
|
||||
app.HandleWithResponse("GET", "/route", response.RawJSON(response.MustWithErrors(local, response.MustNew(format))), handler, bridge)
|
||||
for _, path := range []string{"/global", "/route"} {
|
||||
for _, locale := range []string{"en", "zh-CN"} {
|
||||
for _, mode := range []string{"", "auth", "panic", "missing"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Accept-Language", locale)
|
||||
r.Header.Set("X-Early", mode)
|
||||
r.Header.Set("X-Params", mode)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
var body struct {
|
||||
Error struct {
|
||||
Reason string `json:"reason"`
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"error"`
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err, w.Body.String())
|
||||
}
|
||||
ns, status := "app", 200
|
||||
if path == "/route" {
|
||||
ns, status = "route", 409
|
||||
}
|
||||
want := ns + ":{literal}"
|
||||
if locale != "en" {
|
||||
want = ns + "译文:{literal}"
|
||||
}
|
||||
if mode == "auth" {
|
||||
status = 401
|
||||
want = "fixed {literal}"
|
||||
if locale != "en" {
|
||||
want = "需要认证"
|
||||
}
|
||||
} else if mode == "panic" || mode == "missing" {
|
||||
status = 500
|
||||
want = "internal"
|
||||
if locale != "en" {
|
||||
want = "系统错误"
|
||||
}
|
||||
}
|
||||
if w.Code != status || body.OK || body.Error.Reason != want || strings.Contains(w.Body.String(), "private-value") {
|
||||
t.Fatal(path, locale, mode, w.Code, w.Body.String())
|
||||
}
|
||||
if mode == "" && body.Error.ID.String() != "9223372036854775807" {
|
||||
t.Fatal("public code changed", body.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, shared := range []bool{false, true} {
|
||||
cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "test", nil }}
|
||||
if shared {
|
||||
cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
|
||||
}
|
||||
idem, err := middleware.NewIdempotency(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/localReplay"
|
||||
if shared {
|
||||
path = "/sharedReplay"
|
||||
}
|
||||
calls := 0
|
||||
app.POST(path, func(c *httpx.Context) error { calls++; return handler(c) }, idem.Middleware())
|
||||
var first string
|
||||
for _, locale := range []string{"en", "zh-CN"} {
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(`{}`))
|
||||
r.Header.Set("Idempotency-Key", "same")
|
||||
r.Header.Set("Accept-Language", locale)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if locale == "en" {
|
||||
first = w.Body.String()
|
||||
} else if w.Body.String() != first || w.Header().Get("Idempotency-Replayed") != "true" {
|
||||
t.Fatal("replay changed", w.Body.String(), first)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatal("language retriggered business", calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestI18nExplicitProtocolsAndConcurrentApps(t *testing.T) {
|
||||
apps := []*omni.App{}
|
||||
for _, ns := range []string{"one", "two"} {
|
||||
c := localizedCatalog(ns, 200)
|
||||
p, l := localizedOptions(t, c)
|
||||
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(l))
|
||||
defer a.Close()
|
||||
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||
a.HandleWithResponse("GET", "/ack", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
|
||||
if !r.IsError() || r.Message != "fixed {literal}" || r.PublicMessage() != "需要认证" {
|
||||
t.Error(r)
|
||||
}
|
||||
return response.Text(200, "ACK"), nil
|
||||
}), func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||
a.GET("/direct", func(c *httpx.Context) error { return c.Text(200, "fixed-protocol") })
|
||||
apps = append(apps, a)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 60; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
locale, want := "en", "fixed {literal}"
|
||||
if n%2 == 0 {
|
||||
locale, want = "zh-CN", "需要认证"
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Language", locale)
|
||||
w := httptest.NewRecorder()
|
||||
apps[n%len(apps)].ServeHTTP(w, r)
|
||||
if w.Code != 401 || !strings.Contains(w.Body.String(), want) {
|
||||
t.Error(w.Code, w.Body.String())
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, path := range []string{"/ack", "/direct"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Accept-Language", "zh-CN")
|
||||
w := httptest.NewRecorder()
|
||||
apps[0].ServeHTTP(w, r)
|
||||
want := "ACK"
|
||||
if path == "/direct" {
|
||||
want = "fixed-protocol"
|
||||
}
|
||||
if w.Body.String() != want {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type panickingLocalizer struct{}
|
||||
|
||||
func (panickingLocalizer) Localize(context.Context, *apierrors.Registry, *apierrors.APIError) (string, bool) {
|
||||
panic("presentation failed")
|
||||
}
|
||||
func TestI18nAdapterFailurePreservesBusiness(t *testing.T) {
|
||||
c := localizedCatalog("test", 200)
|
||||
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithErrorLocalizer(panickingLocalizer{}))
|
||||
defer a.Close()
|
||||
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != 401 || !strings.Contains(w.Body.String(), "fixed {literal}") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
59
cmd/go-omni/apigen/corsConsumer_test.go
Normal file
59
cmd/go-omni/apigen/corsConsumer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,15 @@ import (
|
||||
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||
type ErrorDefinition struct {
|
||||
Name string `json:"name"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
Params []apierrors.Parameter `json:"params,omitempty"`
|
||||
Catalog string `json:"catalog,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
Params []apierrors.Parameter `json:"params,omitempty"`
|
||||
Translation *apierrors.Translation `json:"translation,omitempty"`
|
||||
Catalog string `json:"catalog,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
}
|
||||
type AuthDefinition struct {
|
||||
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
||||
|
||||
@@ -49,11 +49,12 @@ func (p *parser) parseErrorCatalog() error {
|
||||
}
|
||||
}
|
||||
type entry struct {
|
||||
Key string `json:"key"`
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
Params []apierrors.Parameter `json:"params"`
|
||||
Key string `json:"key"`
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
Params []apierrors.Parameter `json:"params"`
|
||||
Translation *apierrors.Translation `json:"translation"`
|
||||
}
|
||||
var entries map[string]entry
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
||||
@@ -75,7 +76,7 @@ func (p *parser) parseErrorCatalog() error {
|
||||
if e.Status == 0 {
|
||||
e.Status = defaultStatus
|
||||
}
|
||||
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Catalog: name, Position: pos})
|
||||
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Translation: e.Translation, Catalog: name, Position: pos})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -90,7 +91,7 @@ func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
|
||||
if names != nil && !selected[e.Name] {
|
||||
continue
|
||||
}
|
||||
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params}
|
||||
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params, Translation: e.Translation}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
57
cmd/go-omni/apigen/i18nVersion.go
Normal file
57
cmd/go-omni/apigen/i18nVersion.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Translation metadata was introduced in this release. Consumers of older
|
||||
// runtimes must upgrade or use an explicitly supported local replacement.
|
||||
const i18nMinimumVersion = "v0.26.0-preview"
|
||||
|
||||
func checkI18nRuntime(spec *Spec, moduleData []byte, directory string) error {
|
||||
needed := false
|
||||
for _, e := range spec.Errors {
|
||||
needed = needed || e.Translation != nil
|
||||
}
|
||||
if !needed {
|
||||
return nil
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version := ""
|
||||
for _, r := range mf.Require {
|
||||
if r.Mod.Path == frameworkModule {
|
||||
version = r.Mod.Version
|
||||
}
|
||||
}
|
||||
for _, r := range mf.Replace {
|
||||
if r.Old.Path != frameworkModule || r.Old.Version != "" && r.Old.Version != version {
|
||||
continue
|
||||
}
|
||||
if r.New.Version != "" {
|
||||
version = r.New.Version
|
||||
continue
|
||||
}
|
||||
root := r.New.Path
|
||||
if !filepath.IsAbs(root) {
|
||||
root = filepath.Join(directory, root)
|
||||
}
|
||||
data, e := os.ReadFile(filepath.Join(root, "errors", "localization.go"))
|
||||
if e == nil && strings.Contains(string(data), "type Translation struct") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("translation metadata requires a local framework with errors.Translation support")
|
||||
}
|
||||
if semver.Compare(version, i18nMinimumVersion) < 0 {
|
||||
return fmt.Errorf("translation metadata requires %s or newer; use --framework-dir for unpublished source", i18nMinimumVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
42
cmd/go-omni/apigen/i18n_test.go
Normal file
42
cmd/go-omni/apigen/i18n_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestI18nTranslationMetadataAndVersionBoundary(t *testing.T) {
|
||||
spec, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref := compiled.Spec.Errors[0].Translation
|
||||
if ref == nil || ref.Namespace != "limits" || ref.Key != "exceeded" {
|
||||
t.Fatal(ref)
|
||||
}
|
||||
if err := checkI18nRuntime(spec, []byte("module example.com/test\ngo 1.22\nrequire "+frameworkModule+" v0.25.0-preview\n"), t.TempDir()); err == nil {
|
||||
t.Fatal("old runtime accepted")
|
||||
}
|
||||
generated, _, err := renderProject(Options{Module: "example.com/test"}, compiled, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(generated["internal/apierrors/errorsGen.go"]), "translation") || !strings.Contains(string(generated["docs/openapi.json"]), "translation") {
|
||||
t.Fatal("translation metadata lost")
|
||||
}
|
||||
bad := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
|
||||
bad = strings.Replace(bad, `"namespace":"limits"`, `"namespace":""`, 1)
|
||||
parsed, err := parseText(t, bad)
|
||||
if err == nil {
|
||||
_, err = Compile(parsed)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("invalid translation reference accepted")
|
||||
}
|
||||
}
|
||||
func TestI18nGeneratedConsumer(t *testing.T) { testCatalogConsumer(t, "i18n.api", "i18n") }
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
69
cmd/go-omni/apigen/testdata/cors/consumer_test.go
vendored
Normal file
69
cmd/go-omni/apigen/testdata/cors/consumer_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"example.com/errorCatalog/internal/apierrors"
|
||||
"example.com/errorCatalog/internal/handler"
|
||||
"example.com/errorCatalog/internal/svc"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedTranslationAndParameterCompatibility(t *testing.T) {
|
||||
catalog := apierrors.Catalog()
|
||||
source, err := errori18n.Source(catalog, "en")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source = append(source, i18n.Document{Version: 1, Locale: "zh-Hans", Namespace: "limits", Messages: map[string]i18n.Message{"exceeded": {Value: i18n.Pattern{Text: "上限:{max}"}}}})
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, source...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lc := logx.Default()
|
||||
lc.Writer = io.Discard
|
||||
logger, err := logx.New(lc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
a := omni.New(omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(errori18n.New(b, nil)))
|
||||
defer a.Close()
|
||||
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error { return apierrors.LimitExceeded(9223372036854775807) }
|
||||
})
|
||||
if err := handler.RegisterRoutes(a, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/value", nil)
|
||||
r.Header.Set("Accept-Language", "zh-CN")
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, r)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || body["detail"] != "上限:9223372036854775807" || body["code"] != float64(400001) || body["ok"] != false {
|
||||
t.Fatal(w.Code, body)
|
||||
}
|
||||
if apierrors.LimitExceeded(5).Message != "Limit: 5" {
|
||||
t.Fatal("non-HTTP constructor changed")
|
||||
}
|
||||
}
|
||||
@@ -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
195
cmd/go-omni/i18n.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool"
|
||||
)
|
||||
|
||||
type i18nFlags []string
|
||||
|
||||
func (v *i18nFlags) String() string { return strings.Join(*v, ",") }
|
||||
func (v *i18nFlags) Set(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("empty value")
|
||||
}
|
||||
*v = append(*v, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runI18n(args []string) error { return runI18nTo(args, os.Stdout) }
|
||||
func runI18nTo(args []string, w io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("expected check, export, types or extract")
|
||||
}
|
||||
fs := flag.NewFlagSet("i18n "+args[0], flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var resources, namespaces, goFiles i18nFlags
|
||||
configPath := fs.String("config", "", "i18n Config JSON file")
|
||||
locale := fs.String("locale", "", "selected/source locale")
|
||||
catalog := fs.String("catalog", "", "exported errors.Catalog JSON (extract only)")
|
||||
fs.Var(&resources, "resource", "resource JSON file (repeatable)")
|
||||
fs.Var(&namespaces, "namespace", "export namespace (repeatable)")
|
||||
fs.Var(&goFiles, "go", "Go source file (repeatable)")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return errors.New("unexpected positional arguments")
|
||||
}
|
||||
encode := func(v any) error { e := json.NewEncoder(w); e.SetIndent("", " "); return e.Encode(v) }
|
||||
if args[0] == "extract" && *catalog != "" {
|
||||
if len(goFiles) > 0 || len(resources) > 0 || *configPath != "" || len(namespaces) != 1 || *locale == "" {
|
||||
return errors.New("catalog extraction requires --catalog, --locale and exactly one --namespace")
|
||||
}
|
||||
raw, err := readI18nFile(*catalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var c apierrors.Catalog
|
||||
if err := apierrors.DecodeCatalogJSON(raw, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
docs, err := errori18n.Source(errori18n.Bind(c, namespaces[0]), *locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if doc.Namespace == namespaces[0] {
|
||||
return encode(doc)
|
||||
}
|
||||
}
|
||||
return errors.New("catalog has no messages in the selected namespace")
|
||||
}
|
||||
if *catalog != "" {
|
||||
return errors.New("--catalog is only supported by extract")
|
||||
}
|
||||
if args[0] == "extract" {
|
||||
if len(goFiles) == 0 || len(resources) > 0 || len(namespaces) > 0 || *configPath != "" || *locale != "" {
|
||||
return errors.New("Go extraction requires one or more --go files")
|
||||
}
|
||||
out := i18ntool.Extraction{References: []i18ntool.Reference{}, Findings: []i18ntool.Finding{}}
|
||||
for _, path := range goFiles {
|
||||
raw, err := readI18nFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := i18ntool.ExtractGo(path, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.References = append(out.References, result.References...)
|
||||
out.Findings = append(out.Findings, result.Findings...)
|
||||
}
|
||||
return encode(out)
|
||||
}
|
||||
if args[0] != "check" && args[0] != "export" && args[0] != "types" {
|
||||
return errors.New("expected check, export, types or extract")
|
||||
}
|
||||
if *configPath == "" || len(resources) == 0 {
|
||||
return errors.New("--config and at least one --resource are required")
|
||||
}
|
||||
data, err := readI18nFile(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg i18n.Config
|
||||
if err := i18n.DecodeJSON(data, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
docs := []i18n.Document{}
|
||||
total := 0
|
||||
for _, path := range resources {
|
||||
raw, err := readI18nFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += len(raw)
|
||||
if total > 4<<20 {
|
||||
return errors.New("resources exceed 4 MiB")
|
||||
}
|
||||
var doc i18n.Document
|
||||
if err := i18n.DecodeJSON(raw, &doc); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
bundle, err := i18n.New(cfg, docs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "export":
|
||||
if len(goFiles) > 0 {
|
||||
return errors.New("--go is only supported by check/extract")
|
||||
}
|
||||
return bundle.Export(w, *locale, namespaces...)
|
||||
case "types":
|
||||
if *locale != "" || len(goFiles) > 0 {
|
||||
return errors.New("types does not accept --locale or --go")
|
||||
}
|
||||
return bundle.TypeScript(w, namespaces...)
|
||||
case "check":
|
||||
if *locale != "" || len(namespaces) > 0 {
|
||||
return errors.New("check validates all configured locales and namespaces")
|
||||
}
|
||||
findings := bundle.Check()
|
||||
for _, path := range goFiles {
|
||||
raw, e := readI18nFile(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
ex, e := i18ntool.ExtractGo(path, raw)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for _, r := range ex.References {
|
||||
if _, ok := bundle.Schema(i18n.Ref{Namespace: r.Namespace, Key: r.Key}); !ok {
|
||||
findings = append(findings, i18n.Diagnostic{Kind: "missing", Namespace: r.Namespace, Key: r.Key, Problem: fmt.Sprintf("referenced by %s:%d", r.File, r.Line)})
|
||||
}
|
||||
}
|
||||
for _, f := range ex.Findings {
|
||||
findings = append(findings, i18n.Diagnostic{Kind: "dynamic", Problem: fmt.Sprintf("%s:%d: %s", f.File, f.Line, f.Problem)})
|
||||
}
|
||||
}
|
||||
if err := encode(findings); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
return errors.New("internationalization checks failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func readI18nFile(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
s, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.Mode().IsRegular() {
|
||||
return nil, errors.New("expected a regular file")
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > 1<<20 {
|
||||
return nil, errors.New("file exceeds 1 MiB")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
80
cmd/go-omni/i18n_test.go
Normal file
80
cmd/go-omni/i18n_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
)
|
||||
|
||||
func TestI18nCommandsAreReadOnlyAndCheckMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"config.json": `{"sourceLocale":"en","defaultLocale":"en","locales":["en","fr"]}`,
|
||||
"en.json": `{"version":1,"namespace":"ui","locale":"en","messages":{"hello":{"params":{"type":"string"},"value":{"text":"Hello {type}"}}}}`,
|
||||
"fr.json": `{"version":1,"namespace":"ui","locale":"fr","messages":{"hello":{"value":{"text":"Bonjour {type}"}}}}`,
|
||||
"use.go": "package demo\nimport words \"git.yuntuo.hk/k2safe/go-omni/i18n\"\nvar key=words.Ref{Namespace:\"ui\",Key:\"hello\"}\n",
|
||||
}
|
||||
for path, data := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(data), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
base := []string{"--config", filepath.Join(dir, "config.json"), "--resource", filepath.Join(dir, "en.json"), "--resource", filepath.Join(dir, "fr.json")}
|
||||
var out bytes.Buffer
|
||||
args := append([]string{"check"}, base...)
|
||||
args = append(args, "--go", filepath.Join(dir, "use.go"))
|
||||
if err := runI18nTo(args, &out); err != nil || strings.TrimSpace(out.String()) != "[]" {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
out.Reset()
|
||||
args = append([]string{"export"}, base...)
|
||||
args = append(args, "--locale", "fr", "--namespace", "ui")
|
||||
if err := runI18nTo(args, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pack i18n.Pack
|
||||
if err := json.Unmarshal(out.Bytes(), &pack); err != nil || pack.Locale != "fr" || len(pack.Documents) != 2 {
|
||||
t.Fatal(err, pack)
|
||||
}
|
||||
out.Reset()
|
||||
args = append([]string{"types"}, base...)
|
||||
args = append(args, "--namespace", "ui")
|
||||
if err := runI18nTo(args, &out); err != nil || !strings.Contains(out.String(), `"type": string`) {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := runI18nTo([]string{"extract", "--go", filepath.Join(dir, "use.go")}, &out); err != nil || !strings.Contains(out.String(), `"key": "hello"`) {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
for path, want := range files {
|
||||
got, _ := os.ReadFile(filepath.Join(dir, path))
|
||||
if string(got) != want {
|
||||
t.Fatal("command mutated", path)
|
||||
}
|
||||
}
|
||||
out.Reset()
|
||||
args = append([]string{"check"}, base[:4]...)
|
||||
if err := runI18nTo(args, &out); err == nil || !strings.Contains(out.String(), "direct translation missing") {
|
||||
t.Fatal(err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestI18nCatalogExtractionPreservesLiteralBraces(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "errors.json")
|
||||
if err := os.WriteFile(path, []byte(`{"definitions":{"FIXED":{"code":1,"message":"${NAME}"}}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := runI18nTo([]string{"extract", "--catalog", path, "--namespace", "errors", "--locale", "en"}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var d i18n.Document
|
||||
if err := i18n.DecodeJSON(out.Bytes(), &d); err != nil || !d.Messages["FIXED"].Value.Literal || d.Messages["FIXED"].Value.Text != "${NAME}" {
|
||||
t.Fatal(err, d)
|
||||
}
|
||||
}
|
||||
@@ -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>")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
51
config/cors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
85
config/moduleDiagnostics_test.go
Normal file
85
config/moduleDiagnostics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -380,7 +380,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) {
|
||||
object[name] = value
|
||||
}
|
||||
end, err := dec.Token()
|
||||
if err != nil || end != json.Delim('}') {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end != json.Delim('}') {
|
||||
return nil, errors.New("unterminated object")
|
||||
}
|
||||
return object, nil
|
||||
@@ -394,7 +397,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) {
|
||||
items = append(items, v)
|
||||
}
|
||||
end, err := dec.Token()
|
||||
if err != nil || end != json.Delim(']') {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end != json.Delim(']') {
|
||||
return nil, errors.New("unterminated array")
|
||||
}
|
||||
return items, nil
|
||||
|
||||
@@ -3,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
|
||||
|
||||
@@ -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
192
copyx/check.go
Normal 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
192
copyx/check_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
82
cors/README.md
Normal 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
144
cors/config.go
Normal 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
187
cors/cors.go
Normal 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
303
cors/cors_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`。这是标准驱动接口,核心包不增加具体驱动依赖。项目可以在同一进程创建多个使用不同证书、日志、拨号策略的连接池。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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#框架模块的字段诊断)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
126
database/transaction.go
Normal 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}
|
||||
}
|
||||
56
database/transactionResponse_test.go
Normal file
56
database/transactionResponse_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
441
database/transaction_test.go
Normal file
441
database/transaction_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# 统一错误目录
|
||||
|
||||
v0.26 新增的通用多语言适配见 [i18n](../i18n/README.md)。目录条目可显式引用 `translation: {namespace, key}`;`errori18n.Bind/Source` 支持从同一份 Go 目录生成源词典。翻译只影响公开展示,不改变业务码、HTTP 状态、失败语义、参数契约或 cause。没有引用或没有启用适配时沿用现有行为;幂等重放保留首次语言。该能力需要 v0.26.0-preview 或更新版本。
|
||||
|
||||
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
|
||||
|
||||
## 基本规则
|
||||
|
||||
@@ -9,16 +9,19 @@ import (
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Definition belongs to the project. Code is the public string/integer code;
|
||||
// the Catalog map key is the distinct logical identifier used by ByCode.
|
||||
// Nil Code retains legacy response code mapping (useful when migrating).
|
||||
type Definition struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Params []Parameter `json:"params,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Code any `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Params []Parameter `json:"params,omitempty"`
|
||||
Translation *Translation `json:"translation,omitempty"`
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
@@ -99,6 +102,10 @@ func (c Catalog) Clone() Catalog {
|
||||
out := c
|
||||
out.Definitions = make(map[string]Definition, len(c.Definitions))
|
||||
for key, d := range c.Definitions {
|
||||
if d.Translation != nil {
|
||||
ref := *d.Translation
|
||||
d.Translation = &ref
|
||||
}
|
||||
if d.Params != nil {
|
||||
params := make([]Parameter, len(d.Params))
|
||||
copy(params, d.Params)
|
||||
@@ -126,6 +133,13 @@ func NewRegistry(c Catalog) (*Registry, error) {
|
||||
}
|
||||
messages := map[string][]messagePart{}
|
||||
for key, d := range c.Definitions {
|
||||
if d.Translation != nil {
|
||||
for _, value := range []string{d.Translation.Namespace, d.Translation.Key} {
|
||||
if value == "" || len(value) > 256 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.IndexFunc(value, unicode.IsControl) >= 0 {
|
||||
return nil, fmt.Errorf("errors: %s has invalid translation reference", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
|
||||
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
|
||||
}
|
||||
|
||||
46
errors/localization.go
Normal file
46
errors/localization.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package apierrors
|
||||
|
||||
import "context"
|
||||
|
||||
// Translation is an explicit reference into an independently owned dictionary.
|
||||
// It does not change the logical error key, status, public code or parameters.
|
||||
type Translation struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// Localizer is an optional presentation adapter. false preserves the original
|
||||
// public message. Implementations must support concurrent calls and must not
|
||||
// mutate registry or failure, or expose causes/parameter values in diagnostics.
|
||||
type Localizer interface {
|
||||
Localize(context.Context, *Registry, *APIError) (string, bool)
|
||||
}
|
||||
|
||||
// Definition returns detached metadata for the effective catalog scope.
|
||||
func (r *Registry) Definition(key string) (Definition, bool) {
|
||||
if r == nil {
|
||||
return Definition{}, false
|
||||
}
|
||||
d, ok := r.catalog.Definitions[key]
|
||||
if d.Params != nil {
|
||||
d.Params = append([]Parameter{}, d.Params...)
|
||||
}
|
||||
if d.Translation != nil {
|
||||
ref := *d.Translation
|
||||
d.Translation = &ref
|
||||
}
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// Parameters returns detached public template arguments. This is for explicit
|
||||
// presentation adapters, not automatic serialization into an API response.
|
||||
func (e *APIError) Parameters() map[string]any {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(e.params))
|
||||
for k, v := range e.params {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
34
examples/contracts/i18n.api
Normal file
34
examples/contracts/i18n.api
Normal file
@@ -0,0 +1,34 @@
|
||||
syntax = "v1"
|
||||
|
||||
errorCatalog CommonErrors (
|
||||
defaultStatus: 200
|
||||
errors: {
|
||||
"LimitExceeded": {
|
||||
"key": "LIMIT_EXCEEDED", "code": 400001,
|
||||
"message": "Limit: {max}",
|
||||
"params": [{"name":"max","type":"int64","example":100}],
|
||||
"translation": {"namespace":"limits","key":"exceeded"}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response CommonResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
body: {"code":"${code}", "detail":"${message}", "ok":"${ok}"}
|
||||
)
|
||||
|
||||
type ValueResponse {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
@server (
|
||||
group: limits
|
||||
response: CommonResponse
|
||||
errors: LimitExceeded
|
||||
)
|
||||
service localized-api {
|
||||
@handler value
|
||||
get /value returns (ValueResponse)
|
||||
}
|
||||
17
examples/cors/README.md
Normal file
17
examples/cors/README.md
Normal 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
10
examples/cors/config.yaml
Normal 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
76
examples/cors/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
18
examples/developerTools/README.md
Normal file
18
examples/developerTools/README.md
Normal 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)。
|
||||
7
examples/developerTools/config.yaml
Normal file
7
examples/developerTools/config.yaml
Normal 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}"
|
||||
104
examples/developerTools/main.go
Normal file
104
examples/developerTools/main.go
Normal 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
28
examples/i18n/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 独立国际化、HTTP 和前端示例
|
||||
|
||||
不依赖数据库、其他项目或外部服务。默认作为 CLI 运行;同一 Bundle 也接入 HTTP 错误和前端导出。
|
||||
|
||||
```sh
|
||||
go run ./examples/i18n
|
||||
go run ./examples/i18n -serve 127.0.0.1:8098
|
||||
```
|
||||
|
||||
访问 `http://127.0.0.1:8098/`,输入草稿并切换语言。只更新标题、标签和按钮文字;输入内容、提交次数和表单节点保留。浏览器模拟 `/locales/zh-Hans.json` 加载失败时保留当前语言;快速连续切换不会被旧加载覆盖。
|
||||
|
||||
```sh
|
||||
curl -i -H 'Accept-Language: zh-CN' http://127.0.0.1:8098/api/limit
|
||||
```
|
||||
|
||||
返回 HTTP 409,业务码 400001,中文公开提示;改成 `en` 时只改变展示文案。静态页面和语言包使用直接输出,不受公共响应格式包装。
|
||||
|
||||
项目管理普通 UI 词典;错误源文案只在 Go Catalog 中维护,`errori18n.Source` 在装配时提供源词典,JSON 只维护中文译文。所有语言包显式嵌入,不受进程工作目录或环境变量影响。
|
||||
|
||||
```sh
|
||||
go run ./examples/i18n -export-locale zh-Hans
|
||||
go run ./examples/i18n -catalog > /tmp/omni-i18n-errors.json
|
||||
go run ./cmd/go-omni i18n extract --catalog /tmp/omni-i18n-errors.json \
|
||||
--locale en --namespace errors
|
||||
go test ./examples/i18n
|
||||
```
|
||||
|
||||
前端 Pack 只导出 `ui` 命名空间,不把错误业务码、HTTP 状态、内部 cause 或未选择的文案混进去。更多检查与类型导出命令见 [通用说明](../../i18n/README.md)。
|
||||
6
examples/i18n/config.json
Normal file
6
examples/i18n/config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sourceLocale": "en",
|
||||
"defaultLocale": "en",
|
||||
"locales": ["en", "zh-Hans"],
|
||||
"fallbacks": {"zh-Hans": ["en"]}
|
||||
}
|
||||
46
examples/i18n/index.html
Normal file
46
examples/i18n/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoOmni language example</title>
|
||||
<style>
|
||||
body{font:17px system-ui,sans-serif;max-width:620px;margin:60px auto;padding:0 24px;color:#172c36;background:#f6f8fa}h1{font-size:30px}label{display:block;margin:24px 0 8px}input,select,button{font:inherit;padding:10px;border:1px solid #9babb3;border-radius:6px}input{box-sizing:border-box;width:100%}button{margin-top:16px;background:#17627a;color:white}p{min-height:24px}#status{color:#a12626}
|
||||
</style>
|
||||
<main>
|
||||
<select id="language" aria-label="Language"><option value="en">English</option><option value="zh-Hans">简体中文</option></select>
|
||||
<h1 id="title">Language example</h1>
|
||||
<form id="form">
|
||||
<label id="draftLabel" for="draft">Your draft</label>
|
||||
<input id="draft" name="draft" autocomplete="off">
|
||||
<button id="submit" type="submit">Submit demo</button>
|
||||
</form>
|
||||
<p id="count" aria-live="polite"></p>
|
||||
<p id="status" role="status"></p>
|
||||
</main>
|
||||
<script type="module">
|
||||
import {createLanguageController, fetchPack} from '/runtime.mjs';
|
||||
const select=document.querySelector('#language'), status=document.querySelector('#status');
|
||||
const controller=createLanguageController(await fetchPack('/locales/en.json'), locale=>fetchPack(`/locales/${encodeURIComponent(locale)}.json`));
|
||||
let count=0;
|
||||
function paint(){
|
||||
const {t,locale}=controller.current;
|
||||
document.documentElement.lang=locale;
|
||||
document.querySelector('#title').textContent=t('ui','title');
|
||||
document.querySelector('#draftLabel').textContent=t('ui','draft');
|
||||
document.querySelector('#submit').textContent=t('ui','submit');
|
||||
document.querySelector('#count').textContent=t('ui','submitted',{count});
|
||||
select.value=locale;
|
||||
}
|
||||
select.addEventListener('change',async()=>{
|
||||
status.textContent='';
|
||||
try{if(await controller.setLanguage(select.value))paint();}
|
||||
catch{select.value=controller.current.locale;status.textContent='Language could not be loaded. / 语言加载失败。';}
|
||||
});
|
||||
document.querySelector('#form').addEventListener('submit',async event=>{
|
||||
event.preventDefault();
|
||||
const response=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','Accept-Language':controller.current.locale},body:JSON.stringify({draft:document.querySelector('#draft').value})});
|
||||
if(response.ok){count++;paint();}
|
||||
});
|
||||
paint();
|
||||
</script>
|
||||
</html>
|
||||
8
examples/i18n/locales/errors.zh-Hans.json
Normal file
8
examples/i18n/locales/errors.zh-Hans.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "errors",
|
||||
"locale": "zh-Hans",
|
||||
"messages": {
|
||||
"LIMIT_EXCEEDED": {"value": {"text": "数量不能超过 {max}"}}
|
||||
}
|
||||
}
|
||||
15
examples/i18n/locales/ui.en.json
Normal file
15
examples/i18n/locales/ui.en.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "ui",
|
||||
"locale": "en",
|
||||
"messages": {
|
||||
"title": {"value": {"text": "Language example"}},
|
||||
"draft": {"value": {"text": "Your draft"}},
|
||||
"submit": {"value": {"text": "Submit demo"}},
|
||||
"greeting": {"params": {"name": "string"}, "value": {"text": "Hello, {name}"}},
|
||||
"submitted": {"params": {"count": "integer"}, "value": {"plural": "count", "cases": {
|
||||
"one": {"text": "Submitted {count} time"},
|
||||
"other": {"text": "Submitted {count} times"}
|
||||
}}}
|
||||
}
|
||||
}
|
||||
12
examples/i18n/locales/ui.zh-Hans.json
Normal file
12
examples/i18n/locales/ui.zh-Hans.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "ui",
|
||||
"locale": "zh-Hans",
|
||||
"messages": {
|
||||
"title": {"value": {"text": "语言切换示例"}},
|
||||
"draft": {"value": {"text": "你的草稿"}},
|
||||
"submit": {"value": {"text": "提交演示"}},
|
||||
"greeting": {"value": {"text": "你好,{name}"}},
|
||||
"submitted": {"value": {"plural": "count", "cases": {"other": {"text": "已提交 {count} 次"}}}}
|
||||
}
|
||||
}
|
||||
135
examples/i18n/main.go
Normal file
135
examples/i18n/main.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
browseri18n "git.yuntuo.hk/k2safe/go-omni/i18n/browser"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
//go:embed config.json locales/*.json index.html
|
||||
var resources embed.FS
|
||||
|
||||
func catalog() apierrors.Catalog {
|
||||
return errori18n.Bind(apierrors.Catalog{Definitions: map[string]apierrors.Definition{
|
||||
"LIMIT_EXCEEDED": {Status: 409, Code: 400001, Message: "Count must not exceed {max}", Params: []apierrors.Parameter{{Name: "max", Type: "int64"}}},
|
||||
}}, "errors")
|
||||
}
|
||||
|
||||
func bundle() (*i18n.Bundle, error) {
|
||||
raw, err := resources.ReadFile("config.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg i18n.Config
|
||||
if err := i18n.DecodeJSON(raw, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs, err := i18n.LoadFS(resources, "locales/ui.en.json", "locales/ui.zh-Hans.json", "locales/errors.zh-Hans.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := errori18n.Source(catalog(), cfg.SourceLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs = append(docs, source...)
|
||||
return i18n.New(cfg, docs...)
|
||||
}
|
||||
|
||||
func application(b *i18n.Bundle) (*omni.App, error) {
|
||||
policy, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localizer := errori18n.New(b, nil)
|
||||
if err := localizer.Validate(catalog()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := omni.New(omni.WithErrors(catalog()), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999}), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
|
||||
a.GET("/", func(c *httpx.Context) error {
|
||||
data, err := resources.ReadFile("index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Bytes(200, "text/html; charset=utf-8", data)
|
||||
})
|
||||
a.GET("/runtime.mjs", func(c *httpx.Context) error {
|
||||
data, err := fs.ReadFile(browseri18n.Files(), "runtime.mjs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Bytes(200, "text/javascript; charset=utf-8", data)
|
||||
})
|
||||
a.GET("/locales/:file", func(c *httpx.Context) error {
|
||||
var locale string
|
||||
switch c.Param("file") {
|
||||
case "en.json":
|
||||
locale = "en"
|
||||
case "zh-Hans.json":
|
||||
locale = "zh-Hans"
|
||||
default:
|
||||
return apierrors.NotFound("NOT_FOUND", "language pack not found")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := b.Export(&out, locale, "ui"); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Bytes(200, "application/json; charset=utf-8", out.Bytes())
|
||||
})
|
||||
a.GET("/api/limit", func(*httpx.Context) error {
|
||||
return apierrors.ByCode("LIMIT_EXCEEDED").WithParams(map[string]any{"max": int64(100)})
|
||||
})
|
||||
a.POST("/api/action", func(c *httpx.Context) error { return c.OK(map[string]bool{"accepted": true}) })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
serve := flag.String("serve", "", "optional local HTTP listen address")
|
||||
locale := flag.String("export-locale", "", "export a UI language pack to stdout")
|
||||
exportCatalog := flag.Bool("catalog", false, "export the Go error catalog to stdout")
|
||||
flag.Parse()
|
||||
if err := run(*serve, *locale, *exportCatalog); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run(serve, locale string, exportCatalog bool) error {
|
||||
if exportCatalog {
|
||||
return apierrors.ExportCatalog(os.Stdout, catalog())
|
||||
}
|
||||
b, err := bundle()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locale != "" {
|
||||
return b.Export(os.Stdout, locale, "ui")
|
||||
}
|
||||
if serve != "" {
|
||||
a, err := application(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.Close()
|
||||
return a.Run(serve)
|
||||
}
|
||||
for _, l := range b.Config().Locales {
|
||||
result, err := b.Render(l, i18n.Ref{Namespace: "ui", Key: "greeting"}, map[string]any{"name": "Alex"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(result.Locale + ": " + result.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
34
examples/i18n/main_test.go
Normal file
34
examples/i18n/main_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndependentExampleAndHTTP(t *testing.T) {
|
||||
b, err := bundle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if missing := b.Check(); len(missing) != 0 {
|
||||
t.Fatal(missing)
|
||||
}
|
||||
a, err := application(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
for _, test := range []struct {
|
||||
path, part string
|
||||
status int
|
||||
}{{"/api/limit", "数量不能超过 100", 409}, {"/locales/zh-Hans.json", `"namespace": "ui"`, 200}, {"/runtime.mjs", "createLanguageController", 200}, {"/", "draft", 200}} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", test.path, nil)
|
||||
r.Header.Set("Accept-Language", "zh-CN")
|
||||
a.ServeHTTP(w, r)
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.part) {
|
||||
t.Fatal(test.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
24
examples/transactions/README.md
Normal file
24
examples/transactions/README.md
Normal 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)。
|
||||
48
examples/transactions/demoDriver.go
Normal file
48
examples/transactions/demoDriver.go
Normal 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 }
|
||||
55
examples/transactions/main.go
Normal file
55
examples/transactions/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
2
go.mod
@@ -17,6 +17,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.31.0
|
||||
golang.org/x/mod v0.21.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/text v0.21.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
@@ -48,6 +49,5 @@ require (
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
174
httpc/status_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -53,6 +53,15 @@ func (c *Context) writeResult(result response.Result) (err error) {
|
||||
if encoder == nil {
|
||||
encoder = response.Default()
|
||||
}
|
||||
if c.errorLocalizer != nil && result.Err != nil {
|
||||
if message, ok := c.localizedMessage(result.Err); ok {
|
||||
result.MessageOverride = &message
|
||||
if c.failure != nil {
|
||||
c.failure = c.failure.Clone()
|
||||
c.failure.Message = message
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, err := encoder.Encode(result)
|
||||
if err != nil {
|
||||
return apierrors.Internal(err)
|
||||
@@ -69,6 +78,17 @@ func (c *Context) writeResult(result response.Result) (err error) {
|
||||
return c.WriteResponse(payload.Status, payload.Header, payload.Body)
|
||||
}
|
||||
|
||||
// Presentation adapter failures cannot turn a business result into a new error.
|
||||
func (c *Context) localizedMessage(err error) (message string, ok bool) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
message, ok = "", false
|
||||
}
|
||||
}()
|
||||
registry := c.ErrorRegistry()
|
||||
return c.errorLocalizer.Localize(c.Context(), registry, registry.Resolve(err))
|
||||
}
|
||||
|
||||
// RespondStatus uses the current response format and error catalog, but keeps
|
||||
// the explicit HTTP status even if a catalog/custom encoder selects another.
|
||||
// This is useful for protocol endpoints such as health probes. Ordinary
|
||||
|
||||
180
i18n/README.md
Normal file
180
i18n/README.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 通用国际化
|
||||
|
||||
本功能从 `v0.26.0-preview` 提供。`i18n` 可以单独用于 CLI、后台任务、通知或 Web 服务,不依赖 App、HTTP、错误目录、数据库或前端框架。业务文案、启用语言和用户偏好由接入项目维护。
|
||||
|
||||
模块没有全局当前语言、全局可变词典或自动文件扫描。词典构建后只读,支持多个实例、多个命名空间和并发调用。完整可运行示例见 [examples/i18n](../examples/i18n/README.md),生成契约见 [i18n.api](../examples/contracts/i18n.api)。
|
||||
|
||||
## 独立使用
|
||||
|
||||
```go
|
||||
bundle, err := i18n.New(i18n.Config{
|
||||
SourceLocale: "en", DefaultLocale: "en",
|
||||
Locales: []string{"en", "zh-Hans"},
|
||||
},
|
||||
i18n.Document{Version: 1, Namespace: "common", Locale: "en",
|
||||
Messages: map[string]i18n.Message{
|
||||
"greeting": {
|
||||
Params: map[string]i18n.Kind{"name": i18n.String},
|
||||
Value: i18n.Pattern{Text: "Hello, {name}"},
|
||||
},
|
||||
}},
|
||||
i18n.Document{Version: 1, Namespace: "common", Locale: "zh-Hans",
|
||||
Messages: map[string]i18n.Message{
|
||||
"greeting": {Value: i18n.Pattern{Text: "你好,{name}"}},
|
||||
}},
|
||||
)
|
||||
if err != nil { return err }
|
||||
result, err := bundle.Render("zh-CN", i18n.Ref{
|
||||
Namespace: "common", Key: "greeting",
|
||||
}, map[string]any{"name": "Alex"})
|
||||
// result.Text == "你好,Alex"; result.Locale == "zh-Hans"
|
||||
```
|
||||
|
||||
`Render` 显式接受语言。也可以用 `WithLocale(ctx, locale)` 和 `RenderContext`,语言只属于该上下文。任务消费者需由项目显式取得接收者语言;Go context 不会自动跨进程或写入队列。
|
||||
|
||||
`Result` 提供文本、匹配后的请求语言、实际词典语言和是否发生回退。缺键、缺参、类型错误、渲染限额返回 `*Diagnostic`;核心不决定 HTTP 状态,也不把任意错误自动转换成业务错误。诊断没有参数值、内部原因或原始文案。
|
||||
|
||||
## 语言选择与回退
|
||||
|
||||
- `SourceLocale` 是定义消息和参数的语言,可以只用作最终回退而不对用户开放。
|
||||
- `DefaultLocale` 是无合适偏好时的选择,必须在 `Locales` 中。
|
||||
- `Locales` 是有序的启用语言。`Match` 使用 `x/text/language` 的 BCP 47 匹配,返回真实配置的词典标签;不会用匹配器生成的带扩展标签直接索引文件。
|
||||
- `Fallbacks` 是显式、有序、无环的关系。先查选中语言,再深度优先查配置的回退;然后查默认语言及其回退,最后查源语言,过程中去重。不会从文件名或地域字符串猜一条词典继承链。
|
||||
- 非法请求偏好忽略并使用可用偏好或默认语言;非法配置、重复规范化语言、回退循环在 `New` 时失败。
|
||||
|
||||
运行时允许部分语言缺词,`Check` 单独列出各启用语言缺少的直接翻译。CI 可因此失败,在线请求仍使用回退。存在但格式损坏的译文在构建时拒绝;替换词典应先构建新实例,成功后由项目替换实例。
|
||||
|
||||
## 文案资源 v1
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "notifications",
|
||||
"locale": "en",
|
||||
"messages": {
|
||||
"unread": {
|
||||
"params": {"count": "integer"},
|
||||
"value": {
|
||||
"plural": "count",
|
||||
"cases": {
|
||||
"=0": {"text": "No unread messages"},
|
||||
"one": {"text": "{count} unread message"},
|
||||
"other": {"text": "{count} unread messages"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同一命名空间的同一键只有一份源定义。译文省略 `params` 时继承源定义;重复声明时必须完全一致。参数是消息级契约:各语言使用相同参数集合,分支可以不使用其中某个参数,但整条消息的所有分支合起来必须使用全部参数。
|
||||
|
||||
`value` 支持:
|
||||
|
||||
- `text`:命名插值 `{name}`,`{{`、`}}` 表示字面花括号;空字符串是合法译文。
|
||||
- `literal: true`:文本原样保留,适用于已有固定消息。插入参数不会再次解析,也不会自动查词典。
|
||||
- `select`:根据 string/boolean 参数选择命名分支,必须有 `other`。
|
||||
- `plural`:根据 integer/number 参数选择基数复数分支,必须有 `other`;先匹配 `=0` 等精确 int64 分支,再匹配 `zero/one/two/few/many/other`。可与 select 嵌套。
|
||||
|
||||
复数规则固定为 [Unicode CLDR JSON 46.1.0](https://github.com/unicode-org/cldr-json/blob/46.1.0/cldr-json/cldr-core/supplemental/plurals.json)。Go 和浏览器使用同一份规则,导出包携带实际语言及回退语言的规则,避免浏览器自带规则版本不同。数据来源和许可见 [cldr](cldr/README.md)。规则版本与文案格式版本分别记录。
|
||||
|
||||
参数类型:
|
||||
|
||||
| 类型 | Go 调用 | 浏览器调用 |
|
||||
| --- | --- | --- |
|
||||
| `string` | 字符串 | string |
|
||||
| `boolean` | bool | boolean |
|
||||
| `integer` | int/int32/int64 等整数,或精确的 json.Number;范围为 int64 | 安全整数 number、bigint 或十进制整数字符串;超范围拒绝 |
|
||||
| `number` | 有限 float32/float64 或 json.Number | 有限 number |
|
||||
|
||||
Number 按规范化数值处理:`1.0` 与 `1` 等价,负零显示为 `0`,不保留输入小数位数。整数不会经过 float64;不接受任意对象、Stringer、nil、自动字符串转数值。日期、货币、精度敏感的小数和字段标签可由项目显式格式化为字符串参数,时区与币种不会从语言推断。
|
||||
|
||||
v1 的范围是普通文本、命名参数、select 和基数复数,不宣称兼容完整 ICU MessageFormat;不含 ordinal、紧凑数字格式、HTML 执行或任意表达式。复数计算使用普通数值,CLDR 的 compact-number `c/e` 操作数为 0。字段文案可以先显式 Render 后再作为参数;用户输入和业务数据保持原样。
|
||||
|
||||
## 加载、覆盖与资源限制
|
||||
|
||||
`LoadFS(fs.FS, paths...)` 读取明确列出的 JSON 文件,支持 `embed.FS` 和 `os.DirFS`。`DecodeJSON` 可用于业务自己取得的内存数据,拒绝重复键、未知字段、无效 UTF-8、超深结构。它们不展开 `${...}`,不执行项目代码,不自动拉远程词典。文件系统实际访问范围由传入的 FS 决定。
|
||||
|
||||
项目已有配置加载器可加载 `i18n.Config` 的语言设置,词典正文用本包加载;避免配置加载器把词典里的 `${...}` 解释成环境变量。数据库或远程词典由项目读取为 Document,再调用 New。
|
||||
|
||||
同一语言、命名空间可拆成多个文件;重复键默认报错。`Overlay(base, override)` 明确替换完整消息并返回独立副本,再传给 New 校验;不隐式合并分支或参数。项目决定覆盖的先后顺序。
|
||||
|
||||
主要限额:每个 JSON 文件 1 MiB、LoadFS 总量 4 MiB、最多 1024 个文档、64 个资源语言、128 个命名空间、10000 条消息、每条 64 个参数、单文本/字符串参数 64 KiB、模式深度 24、渲染结果 1 MiB。导出的单语言包最多 4 MiB,可按命名空间拆分。
|
||||
|
||||
`Config()`、`Documents()` 和 `Schema()` 返回独立副本;`Config.Validate()` 可预检语言配置。运行时没有修改词典的方法。
|
||||
|
||||
## 接入 go-omni 错误与 HTTP
|
||||
|
||||
```go
|
||||
// 项目自己的 Catalog,既有 status/code/message/params 继续维护在这里。
|
||||
catalog = errori18n.Bind(catalog, "accountErrors")
|
||||
sourceDocuments, err := errori18n.Source(catalog, "en")
|
||||
// 把 sourceDocuments 与项目加载的译文一起传入 i18n.New。
|
||||
// Bind 不修改输入;已有显式 translation 引用保持。
|
||||
|
||||
languagePolicy, err := httpi18n.New(bundle)
|
||||
localizer := errori18n.New(bundle, nil) // 第二参数可提供并发安全的诊断回调。
|
||||
if err := localizer.Validate(catalog); err != nil { return err }
|
||||
|
||||
app := omni.New(
|
||||
omni.WithErrors(catalog),
|
||||
omni.WithLanguagePolicy(languagePolicy),
|
||||
omni.WithErrorLocalizer(localizer),
|
||||
// 继续传入项目自己的 WithResponse / WithResponseEncoder。
|
||||
)
|
||||
```
|
||||
|
||||
也可在单项 Definition 或 `.api errorCatalog` 条目明确声明:
|
||||
|
||||
```json
|
||||
"translation": {"namespace":"accountErrors", "key":"username.invalid"}
|
||||
```
|
||||
|
||||
翻译引用与逻辑错误标识、对外业务码分别独立。不同目录可以为同名错误配置不同引用;多个错误引用同一源消息时,Source 会拒绝不一致的源文案或参数。Go 源目录通过 Source 导出,保留原固定文案的字面花括号,不包含状态、业务码、example 或 cause。
|
||||
|
||||
HTTP 策略在应用中间件之前选语言,默认读取有界 `Accept-Language`,保留请求上下文中显式设置的语言优先级。`httpi18n.WithResolver` 可替换语言来源,并显式声明缓存 Vary 所需的头;无头可声明的个性化来源自动标记 private。HTTP 策略不会读取用户数据库、JWT 或 Cookie 中的业务字段,也不自动设置可能与逐词回退不符的 Content-Language。独立标准 HTTP 可以用 `Policy.Wrap`。
|
||||
|
||||
自定义 Resolver panic 时按无偏好处理,继续使用显式上下文语言或默认语言,避免偏好查询故障中断业务请求。项目可在自己的 Resolver 中记录诊断。
|
||||
|
||||
错误适配器先使用原有规则解析业务错误,只替换展示文案;缺资源、适配失败保持原文,参数调用错误仍遵循原有 INTERNAL_ERROR 处理。没有 translation 引用的条目保持原行为。标准响应的平铺字段、嵌套 `${message}`、RawJSON 错误、旧 Messages 校验、路由独立目录、认证提前返回和缓冲克隆均保留原语义。
|
||||
|
||||
`response.Result.Message` 继续是契约原文;新增 `MessageOverride` 和 `PublicMessage()`。内置 Formatter 使用最终公开文案。自定义 Encoder 需要翻译时显式使用 `PublicMessage()`;固定回执继续由 Encoder 决定。`c.JSON/Text/XML/Bytes` 等直接输出不自动重写。
|
||||
|
||||
失败快照保存当次展示文案。幂等重放保留首次响应和失败元数据,不重新翻译,不把语言加入业务幂等键。`Localizer.Localize(ctx, registry, resolvedError)` 也可供非 HTTP 入口使用;返回值只包含文本和是否应用,不修改传入错误。
|
||||
|
||||
生成器识别 translation 元数据并写入 Catalog、路由目录和 OpenAPI;已有无 translation 的契约不变。新 metadata 的远程 runtime 下限为 `v0.26.0-preview`,CLI 和框架使用同版;开发框架时可以显式使用本地 checkout。生成到不具备能力的旧依赖会在写入前失败。
|
||||
|
||||
## CLI 提取、检查与导出
|
||||
|
||||
下面的命令在框架根目录可以直接运行;安装后的 CLI 把 `go run ./cmd/go-omni` 换成 `go-omni`。
|
||||
|
||||
```sh
|
||||
go run ./cmd/go-omni i18n check --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json
|
||||
|
||||
go run ./cmd/go-omni i18n export --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json \
|
||||
--locale zh-Hans --namespace ui
|
||||
|
||||
go run ./cmd/go-omni i18n types --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json --namespace ui
|
||||
|
||||
go run ./cmd/go-omni i18n extract --go examples/i18n/main.go
|
||||
```
|
||||
|
||||
- `check` 检查资源和直接翻译遗漏,非空诊断退出非零;可重复提供 `--go` 检查静态引用。
|
||||
- `extract --go` 识别 Go 中 `i18n.Ref` 的字符串字面量及 import 别名;动态引用、非字面量 Render 调用和 dot import 会报告,不能声称完成任意 Go/JS/模板的静态分析。提取命令输出发现结果,check 把未解决动态引用视为诊断。
|
||||
- `extract --catalog exported-errors.json --namespace errors --locale en` 读取已有错误目录导出物,生成单个源语言 Document,不执行项目 Go。目录可以由已有 workspace Go Catalog 导出流程产生。
|
||||
- `export` 显式选择命名空间,输出一个选中语言及全部回退资源的 Pack,不输出业务错误元数据。可重复 `--namespace`。
|
||||
- `types` 输出按 namespace/key 分层的 TypeScript 参数类型及 Translate 类型,不强制 Go 命名风格,不把 int64 简单降成 number。
|
||||
|
||||
这些命令只读输入并输出 stdout,没有自动覆盖业务文件、扫描其他项目、翻译服务调用或安装前端依赖。`Bundle.Export`、`TypeScript` 提供相同的库入口。
|
||||
|
||||
## 前端
|
||||
|
||||
见 [browser](browser/README.md)。运行时是普通 ES module,带 TypeScript 声明,可按项目需要复制进前端或通过 `browseri18n.Files()` 提供静态文件。语言包按需加载、先验证再提交;连续切换只有最新选择能生效,失败保留已提交语言。没有 DOM、localStorage、表单或业务请求的隐式操作。
|
||||
|
||||
升级本模块时必须一起验证资源格式、规则版本、前后端一致性、错误兼容和生成消费者。现有项目不启用这两个适配选项时,响应与错误行为保持原样。
|
||||
39
i18n/browser/README.md
Normal file
39
i18n/browser/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 浏览器运行时
|
||||
|
||||
`runtime.mjs` 是可独立导入的 ES module,`runtime.d.mts` 提供类型。需要支持 ES modules、BigInt、TextEncoder 和 structuredClone 的现代浏览器;Node.js 22 用于本仓库验证。复数规则来自导出包,与 Go 共用固定 CLDR 数据,不调用浏览器自己的 Intl.PluralRules。语言选择使用包中 `locales` 的精确标识,不由浏览器再次改写别名或推断回退链。
|
||||
|
||||
```js
|
||||
import { createLanguageController, fetchPack } from './runtime.mjs';
|
||||
|
||||
const urls = { en: '/locales/en.json', 'zh-Hans': '/locales/zh-Hans.json' };
|
||||
const language = createLanguageController(
|
||||
await fetchPack(urls.en),
|
||||
locale => fetchPack(urls[locale])
|
||||
);
|
||||
|
||||
await language.setLanguage('zh-Hans');
|
||||
const text = language.current.t('ui', 'greeting', { name: 'Alex' });
|
||||
```
|
||||
|
||||
`current.render` 额外返回实际语言和 fallback 标记。`setLanguage` 成功提交返回 true;被更新选择取代返回 false;最新加载失败抛错并保留 current。甚至“切回当前语言”也会取消旧请求的提交资格。未知语言、错误语言包、参数 schema 或启用语言集合变化都不能提交。
|
||||
|
||||
`fetchPack` 检查 HTTP 状态和 4 MiB 限额,支持 AbortSignal 和自定义 fetch。加载资源、鉴权、缓存策略与 URL 映射由调用者决定。它只返回 JSON 数据,`createLocalizer` / controller 负责完整资源检查。
|
||||
|
||||
`overlayPack(pack, documents)` 显式覆盖已导出键的完整消息并重新校验,不修改输入,不允许改变参数契约。例如替换品牌标题;覆写源消息时保留原 params。业务可在 loader 返回之前应用 overrides。
|
||||
|
||||
```ts
|
||||
import type { Translate } from './messages.generated';
|
||||
const t: Translate = language.current.t;
|
||||
// t('ui', 'greeting', { name: 'Alex' })
|
||||
```
|
||||
|
||||
应用需要在切换成功后重新取得 current.t。视图使用 textContent 或框架的文本绑定,动态参数不递归解析、不作为 HTML 执行。金额格式化、RTL 布局、表单、路由、业务提交和偏好持久化属于应用;运行时不重建页面或重新发业务请求。
|
||||
|
||||
完整表单示例:[examples/i18n](../../examples/i18n/README.md)。验证:
|
||||
|
||||
```sh
|
||||
node --test i18n/browser/runtime.test.mjs
|
||||
go test ./i18n -run TestBrowserConformance
|
||||
# PATH 中提供 tsc 时,验证导出类型与实际 runtime 声明及错误参数诊断。
|
||||
go test ./i18n -run TestTypeScriptConsumer -v
|
||||
```
|
||||
13
i18n/browser/assets.go
Normal file
13
i18n/browser/assets.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Package browseri18n embeds the optional framework-neutral browser module.
|
||||
// Frontend projects can also copy/import runtime.mjs directly.
|
||||
package browseri18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed runtime.mjs runtime.d.mts
|
||||
var files embed.FS
|
||||
|
||||
func Files() fs.FS { return files }
|
||||
6
i18n/browser/conformance.mjs
Normal file
6
i18n/browser/conformance.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLocalizer } from "./runtime.mjs";
|
||||
const { pack, cases } = JSON.parse(readFileSync(0, "utf8"));
|
||||
const localizer = createLocalizer(pack);
|
||||
for (const sample of cases) assert.equal(localizer.t("common", sample.key, sample.params), sample.want);
|
||||
29
i18n/browser/runtime.d.mts
Normal file
29
i18n/browser/runtime.d.mts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Kind = "string" | "boolean" | "integer" | "number";
|
||||
export interface Pattern { text?: string; literal?: boolean; select?: string; plural?: string; cases?: Record<string, Pattern>; }
|
||||
export interface Message { params?: Record<string, Kind>; value: Pattern; }
|
||||
export interface Document { version: 1; namespace: string; locale: string; messages: Record<string, Message>; }
|
||||
export interface Configuration { sourceLocale: string; defaultLocale: string; locales: string[]; fallbacks?: Record<string, string[]>; }
|
||||
export interface Pack {
|
||||
version: 1;
|
||||
locale: string;
|
||||
config: Configuration;
|
||||
documents: Document[];
|
||||
ruleVersion: "cldr-46.1";
|
||||
pluralRules: Record<string, Partial<Record<"zero" | "one" | "two" | "few" | "many", string>>>;
|
||||
license?: string;
|
||||
}
|
||||
export interface Rendered { text: string; locale: string; requested: string; fallback: boolean; }
|
||||
export interface Localizer {
|
||||
readonly locale: string;
|
||||
readonly locales: readonly string[];
|
||||
render(namespace: string, key: string, params?: Record<string, unknown>): Rendered;
|
||||
t(namespace: string, key: string, params?: Record<string, unknown>): string;
|
||||
}
|
||||
export class MessageError extends Error { readonly kind: string; constructor(kind: string, problem: string); }
|
||||
export function createLocalizer(pack: Pack): Localizer;
|
||||
export function overlayPack(pack: Pack, overrides: Document[]): Pack;
|
||||
export function createLanguageController(initialPack: Pack, loadPack: (locale: string) => Promise<Pack>): {
|
||||
readonly current: Localizer;
|
||||
setLanguage(locale: string): Promise<boolean>;
|
||||
};
|
||||
export function fetchPack(url: string | URL, options?: { signal?: AbortSignal; fetch?: typeof globalThis.fetch }): Promise<Pack>;
|
||||
305
i18n/browser/runtime.mjs
Normal file
305
i18n/browser/runtime.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
// Framework-neutral runtime for go-omni message resource version 1.
|
||||
// No globals, DOM changes, persistence, HTML insertion, or business requests.
|
||||
const owns = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
||||
const kinds = new Set(["string", "boolean", "integer", "number"]);
|
||||
const pluralCases = new Set(["zero", "one", "two", "few", "many", "other"]);
|
||||
const integer = /^-?(0|[1-9][0-9]*)$/;
|
||||
const paramName = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
||||
const encoder = new TextEncoder();
|
||||
const contractKey = Symbol("messageContract");
|
||||
const bytes = text => encoder.encode(text).length;
|
||||
const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const id = v => typeof v === "string" && v.length > 0 && bytes(v) <= 256 && v.trim() === v && !/[\u0000-\u001f\u007f-\u009f]/u.test(v);
|
||||
const fail = (kind, problem) => { throw new MessageError(kind, problem); };
|
||||
const fields = (value, allowed) => {
|
||||
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail("resource", "invalid resource fields");
|
||||
};
|
||||
const localeTag = value => {
|
||||
// Tags are canonicalized by the Go exporter. Keep their identity verbatim:
|
||||
// host Intl versions can remap aliases (e.g. cmn) or reject private-use tags.
|
||||
if (typeof value !== "string" || bytes(value) > 128 || value === "und" ||
|
||||
!/^(?:[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*|[xX](?:-[A-Za-z0-9]{1,8})+)$/.test(value)) fail("locale", "invalid exported locale identifier");
|
||||
return value;
|
||||
};
|
||||
|
||||
export class MessageError extends Error {
|
||||
constructor(kind, problem) { super(`i18n: ${problem}`); this.name = "MessageError"; this.kind = kind; }
|
||||
}
|
||||
|
||||
function scalar(kind, value) {
|
||||
if (kind === "string" && typeof value === "string" && bytes(value) <= 65536) return value;
|
||||
if (kind === "boolean" && typeof value === "boolean") return String(value);
|
||||
if (kind === "number" && typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (kind === "integer") {
|
||||
let n;
|
||||
if (typeof value === "bigint") n = value;
|
||||
else if (typeof value === "number" && Number.isSafeInteger(value)) n = BigInt(value);
|
||||
else if (typeof value === "string" && value.length <= 20 && integer.test(value)) n = BigInt(value);
|
||||
else fail("parameter", "expected an exact integer");
|
||||
if (n >= -(1n << 63n) && n < (1n << 63n)) return String(n);
|
||||
}
|
||||
fail("parameter", "wrong argument type, range or size");
|
||||
}
|
||||
|
||||
function compile(pattern, schema, used, budget, depth = 0) {
|
||||
fields(pattern, ["text", "literal", "select", "plural", "cases"]);
|
||||
const text = pattern.text ?? "";
|
||||
if (typeof text !== "string" || (pattern.literal !== undefined && typeof pattern.literal !== "boolean")) fail("pattern", "invalid text or literal flag");
|
||||
budget.nodes++; budget.bytes += bytes(text);
|
||||
if (depth > 24 || budget.nodes > 100000 || budget.bytes > 4194304 || bytes(text) > 65536) fail("resource", "pattern exceeds limits");
|
||||
const select = pattern.select ?? "", plural = pattern.plural ?? "";
|
||||
if (typeof select !== "string" || typeof plural !== "string") fail("pattern", "invalid branch parameter");
|
||||
if (select || plural) {
|
||||
if ((select && plural) || text || pattern.literal || !object(pattern.cases)) fail("pattern", "invalid branch");
|
||||
const entries = Object.entries(pattern.cases), key = select || plural;
|
||||
if (!owns(pattern.cases, "other") || entries.length > 128) fail("pattern", "branch requires other and at most 128 cases");
|
||||
const kind = schema.get(key);
|
||||
if (plural ? kind !== "integer" && kind !== "number" : kind !== "string" && kind !== "boolean") fail("parameter", "branch parameter type mismatch");
|
||||
used.add(key);
|
||||
const cases = new Map();
|
||||
for (const [name, child] of entries) {
|
||||
if (!id(name)) fail("pattern", "invalid case name");
|
||||
if (plural && !pluralCases.has(name)) {
|
||||
if (!name.startsWith("=") || !integer.test(name.slice(1)) || scalar("integer", name.slice(1)) !== name.slice(1)) fail("pattern", "invalid plural case");
|
||||
}
|
||||
cases.set(name, compile(child, schema, used, budget, depth + 1));
|
||||
}
|
||||
return { select, plural, cases };
|
||||
}
|
||||
if (pattern.cases !== undefined) fail("pattern", "cases require a branch");
|
||||
if (pattern.literal) return { parts: [{ text }] };
|
||||
const parts = [];
|
||||
let literal = "";
|
||||
const flush = () => { if (literal) parts.push({ text: literal }); literal = ""; };
|
||||
for (let i = 0; i < text.length;) {
|
||||
const ch = text[i];
|
||||
if (ch !== "{" && ch !== "}") { literal += ch; i++; continue; }
|
||||
if (text[i + 1] === ch) { literal += ch; i += 2; continue; }
|
||||
if (ch === "}") fail("pattern", "unescaped closing brace");
|
||||
const end = text.indexOf("}", i + 1);
|
||||
if (end < 0) fail("pattern", "unclosed placeholder");
|
||||
const key = text.slice(i + 1, end);
|
||||
if (!schema.has(key)) fail("parameter", "undeclared placeholder");
|
||||
used.add(key); flush(); parts.push({ param: key }); i = end + 1;
|
||||
}
|
||||
flush(); return { parts };
|
||||
}
|
||||
|
||||
function pluralOperands(text) {
|
||||
const [mantissa, exponent = "0"] = text.replace(/^-/, "").split("e");
|
||||
let [whole, fraction = ""] = mantissa.split(".");
|
||||
const digits = whole + fraction, point = whole.length + Number(exponent);
|
||||
if (point <= 0) { whole = "0"; fraction = "0".repeat(-point) + digits; }
|
||||
else if (point >= digits.length) { whole = digits + "0".repeat(point - digits.length); fraction = ""; }
|
||||
else { whole = digits.slice(0, point); fraction = digits.slice(point); }
|
||||
const tail = fraction.replace(/0+$/, ""), i = BigInt(whole || "0"), f = BigInt(fraction || "0");
|
||||
return { n: i, i, v: BigInt(fraction.length), w: BigInt(tail.length), f, t: BigInt(tail || "0"), c: 0n, e: 0n, fractional: f !== 0n };
|
||||
}
|
||||
|
||||
function compilePluralRules(rules) {
|
||||
if (!object(rules) || Object.keys(rules).some(k => !["zero", "one", "two", "few", "many"].includes(k))) fail("resource", "invalid plural rules");
|
||||
const compiled = new Map();
|
||||
for (const [category, text] of Object.entries(rules)) {
|
||||
if (typeof text !== "string" || text.length > 2048) fail("resource", "invalid plural rule size");
|
||||
compiled.set(category, text.split(" or ").map(or => or.split(" and ").map(and => {
|
||||
const match = /^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$/.exec(and.trim());
|
||||
if (!match) fail("resource", "invalid CLDR plural condition");
|
||||
const ranges = match[4].split(",").map(raw => { const [lo, hi = lo] = raw.trim().split(".."); const range = [BigInt(lo), BigInt(hi)]; if (range[0] > range[1]) fail("resource", "reversed plural range"); return range; });
|
||||
return { operand: match[1], mod: match[2] ? BigInt(match[2]) : undefined, negate: match[3] === "!=", ranges };
|
||||
})));
|
||||
}
|
||||
return text => {
|
||||
const values = pluralOperands(text);
|
||||
for (const category of ["zero", "one", "two", "few", "many"]) {
|
||||
const rule = compiled.get(category);
|
||||
if (rule?.some(group => group.every(condition => {
|
||||
let value = values[condition.operand]; if (condition.mod) value %= condition.mod;
|
||||
let found = (condition.operand !== "n" || !values.fractional) && condition.ranges.some(([lo, hi]) => value >= lo && value <= hi);
|
||||
return condition.negate ? !found : found;
|
||||
}))) return category;
|
||||
}
|
||||
return "other";
|
||||
};
|
||||
}
|
||||
|
||||
function render(pattern, locale, values, pluralRules) {
|
||||
if (pattern.cases) {
|
||||
let key = values.get(pattern.select);
|
||||
if (pattern.plural) {
|
||||
const value = values.get(pattern.plural);
|
||||
key = `=${value}`;
|
||||
if (!pattern.cases.has(key)) key = pluralRules.get(locale)(value);
|
||||
}
|
||||
return render(pattern.cases.get(key) ?? pattern.cases.get("other"), locale, values, pluralRules);
|
||||
}
|
||||
let text = "", size = 0;
|
||||
for (const part of pattern.parts) {
|
||||
const value = part.param === undefined ? part.text : values.get(part.param);
|
||||
size += bytes(value); if (size > 1048576) fail("resource", "rendered message exceeds 1 MiB");
|
||||
text += value;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Compiles a detached pack for one locale. Arguments are never recursively
|
||||
// interpreted as messages or HTML. Applications insert output as text.
|
||||
export function createLocalizer(pack) {
|
||||
fields(pack, ["version", "locale", "config", "documents", "ruleVersion", "pluralRules", "license"]);
|
||||
if (pack.version !== 1 || !Array.isArray(pack.documents) || pack.documents.length > 1024) fail("resource", "unsupported pack version or size");
|
||||
const c = pack.config;
|
||||
fields(c, ["sourceLocale", "defaultLocale", "locales", "fallbacks"]);
|
||||
const source = localeTag(c.sourceLocale), defaultLocale = localeTag(c.defaultLocale), locale = localeTag(pack.locale);
|
||||
if (!Array.isArray(c.locales) || c.locales.length < 1 || c.locales.length > 64) fail("locale", "invalid enabled locales");
|
||||
const locales = c.locales.map(localeTag), enabled = new Set(locales);
|
||||
if (enabled.size !== locales.length || ![defaultLocale, locale].every(l => enabled.has(l))) fail("locale", "duplicate or disabled locale");
|
||||
const fallback = new Map();
|
||||
if (c.fallbacks !== undefined && !object(c.fallbacks)) fail("locale", "invalid fallback configuration");
|
||||
for (const [raw, list] of Object.entries(c.fallbacks ?? {})) {
|
||||
const tag = localeTag(raw);
|
||||
if (!enabled.has(tag) || fallback.has(tag) || !Array.isArray(list) || list.length > 64) fail("locale", "invalid fallback configuration");
|
||||
const targets = list.map(localeTag);
|
||||
if (new Set(targets).size !== targets.length || targets.some(l => !enabled.has(l))) fail("locale", "invalid fallback targets");
|
||||
fallback.set(tag, targets);
|
||||
}
|
||||
const state = new Map();
|
||||
const visit = tag => { if (state.get(tag) === 1) fail("locale", "fallback cycle"); if (state.get(tag) === 2) return; state.set(tag, 1); for (const to of fallback.get(tag) ?? []) visit(to); state.set(tag, 2); };
|
||||
for (const l of locales) visit(l);
|
||||
const chain = [], seen = new Set();
|
||||
const add = l => { if (seen.has(l)) return; seen.add(l); chain.push(l); for (const to of fallback.get(l) ?? []) add(to); };
|
||||
add(locale); add(defaultLocale); add(source);
|
||||
if (pack.ruleVersion !== "cldr-46.1" || !object(pack.pluralRules) || Object.keys(pack.pluralRules).length > 65) fail("resource", "unsupported plural rule version");
|
||||
const pluralRules = new Map();
|
||||
for (const tag of chain) {
|
||||
if (!owns(pack.pluralRules, tag)) fail("resource", "missing plural rules for fallback language");
|
||||
pluralRules.set(tag, compilePluralRules(pack.pluralRules[tag]));
|
||||
}
|
||||
const namespaces = new Map(), documents = [], all = new Map();
|
||||
let count = 0;
|
||||
for (const doc of pack.documents) {
|
||||
fields(doc, ["version", "namespace", "locale", "messages"]);
|
||||
if (doc.version !== 1 || !id(doc.namespace) || !object(doc.messages)) fail("resource", "invalid document");
|
||||
const tag = localeTag(doc.locale);
|
||||
if (!enabled.has(tag) && tag !== source) fail("locale", "pack contains a disabled locale");
|
||||
if (!all.has(tag)) all.set(tag, new Map());
|
||||
const byNamespace = all.get(tag);
|
||||
if (!byNamespace.has(doc.namespace)) byNamespace.set(doc.namespace, new Set());
|
||||
for (const key of Object.keys(doc.messages)) {
|
||||
if (!id(key) || byNamespace.get(doc.namespace).has(key)) fail("resource", "invalid or duplicate message");
|
||||
byNamespace.get(doc.namespace).add(key);
|
||||
if (++count > 10000) fail("resource", "too many messages");
|
||||
}
|
||||
documents.push({ namespace: doc.namespace, tag, messages: doc.messages });
|
||||
}
|
||||
for (const doc of documents.filter(d => d.tag === source)) {
|
||||
if (!namespaces.has(doc.namespace)) namespaces.set(doc.namespace, new Map());
|
||||
const messages = namespaces.get(doc.namespace);
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
fields(message, ["params", "value"]);
|
||||
if (!object(message.value) || message.params !== undefined && !object(message.params)) fail("resource", "message requires value and valid params");
|
||||
const schema = new Map(Object.entries(message.params ?? {}));
|
||||
if (schema.size > 64 || [...schema].some(([k, v]) => !paramName.test(k) || bytes(k) > 128 || !kinds.has(v))) fail("parameter", "invalid parameter schema");
|
||||
messages.set(key, { schema, patterns: new Map() });
|
||||
}
|
||||
}
|
||||
if (namespaces.size > 128) fail("resource", "too many namespaces");
|
||||
const budget = { nodes: 0, bytes: 0 };
|
||||
for (const doc of documents) {
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
fields(message, ["params", "value"]);
|
||||
const entry = namespaces.get(doc.namespace)?.get(key);
|
||||
if (!entry) fail("missing", "translation has no source message");
|
||||
if (message.params !== undefined && (!object(message.params) || Object.keys(message.params).length !== entry.schema.size || Object.entries(message.params).some(([k, v]) => entry.schema.get(k) !== v))) fail("parameter", "translation schema differs from source");
|
||||
const used = new Set();
|
||||
entry.patterns.set(doc.tag, compile(message.value, entry.schema, used, budget));
|
||||
if ([...entry.schema.keys()].some(k => !used.has(k))) fail("parameter", "declared parameter is unused");
|
||||
}
|
||||
}
|
||||
const translate = (namespace, key, params = {}) => {
|
||||
const entry = namespaces.get(namespace)?.get(key);
|
||||
if (!entry) fail("missing", "unknown message");
|
||||
if (!object(params)) fail("parameter", "expected parameter object");
|
||||
const values = new Map();
|
||||
for (const [name, kind] of entry.schema) {
|
||||
if (!owns(params, name)) fail("parameter", "missing argument");
|
||||
values.set(name, scalar(kind, params[name]));
|
||||
}
|
||||
if (Object.keys(params).some(k => !entry.schema.has(k))) fail("parameter", "undeclared argument");
|
||||
for (const tag of chain) {
|
||||
const pattern = entry.patterns.get(tag);
|
||||
if (pattern) return { text: render(pattern, tag, values, pluralRules), locale: tag, requested: locale, fallback: tag !== locale };
|
||||
}
|
||||
fail("missing", "no message in fallback chain");
|
||||
};
|
||||
const contract = JSON.stringify([pack.ruleVersion, source, defaultLocale, locales, [...fallback].sort(), [...namespaces].sort().map(([ns, messages]) => [ns, [...messages].sort().map(([key, entry]) => [key, [...entry.schema].sort()])])]);
|
||||
return Object.freeze({ [contractKey]: contract, locale, locales: Object.freeze([...locales]), render: translate, t: (ns, key, params) => translate(ns, key, params).text });
|
||||
}
|
||||
|
||||
// Only a fully loaded and validated pack can replace the current language.
|
||||
// Last selection wins. A failed latest request keeps the committed language;
|
||||
// older requests cannot commit after it, even when they later succeed.
|
||||
export function createLanguageController(initialPack, loadPack) {
|
||||
if (typeof loadPack !== "function") fail("resource", "loader required");
|
||||
let current = createLocalizer(initialPack), sequence = 0;
|
||||
const enabled = [...current.locales];
|
||||
return Object.freeze({
|
||||
get current() { return current; },
|
||||
async setLanguage(raw) {
|
||||
const ticket = ++sequence, locale = localeTag(raw);
|
||||
if (!enabled.includes(locale)) fail("locale", "locale is not enabled");
|
||||
if (locale === current.locale) return true;
|
||||
let pack;
|
||||
try { pack = await loadPack(locale); }
|
||||
catch (error) { if (ticket !== sequence) return false; throw error; }
|
||||
if (ticket !== sequence) return false;
|
||||
const next = createLocalizer(pack);
|
||||
if (next.locale !== locale || next[contractKey] !== current[contractKey]) fail("locale", "loaded pack does not match requested language/configuration");
|
||||
current = next;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Explicit whole-message overrides. Originals stay untouched; unknown keys and
|
||||
// changed parameter schemas are rejected by validation before the pack is used.
|
||||
export function overlayPack(pack, overrides) {
|
||||
createLocalizer(pack);
|
||||
if (!Array.isArray(overrides) || overrides.length > 1024) fail("resource", "invalid overrides");
|
||||
const result = structuredClone(pack), seen = new Set();
|
||||
for (const doc of overrides) {
|
||||
fields(doc, ["version", "namespace", "locale", "messages"]);
|
||||
if (doc.version !== 1 || !object(doc.messages)) fail("resource", "invalid override document");
|
||||
const tag = localeTag(doc.locale);
|
||||
const target = result.documents.find(d => d.namespace === doc.namespace && localeTag(d.locale) === tag);
|
||||
if (!target) fail("resource", "override must target an exported namespace and locale");
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
const identity = JSON.stringify([tag, doc.namespace, key]);
|
||||
if (seen.has(identity) || !owns(target.messages, key)) fail("resource", "unknown or duplicate override key");
|
||||
seen.add(identity);
|
||||
Object.defineProperty(target.messages, key, { value: structuredClone(message), enumerable: true, writable: true, configurable: true });
|
||||
}
|
||||
}
|
||||
const checked = createLocalizer(result), original = createLocalizer(pack);
|
||||
if (checked[contractKey] !== original[contractKey]) fail("parameter", "overrides cannot change the message contract");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Optional bounded fetch loader. The caller supplies the URL mapping; locale is
|
||||
// never treated as an arbitrary path or endpoint by this runtime.
|
||||
export async function fetchPack(url, { signal, fetch: fetcher = globalThis.fetch } = {}) {
|
||||
const response = await fetcher(url, { signal, credentials: "same-origin" });
|
||||
if (!response.ok) fail("load", "language pack request failed");
|
||||
if (!response.body) fail("load", "language pack response has no body");
|
||||
const reader = response.body.getReader(), chunks = []; let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > 4194304) { await reader.cancel(); fail("resource", "language pack exceeds 4 MiB"); }
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const raw = new Uint8Array(size); let offset = 0;
|
||||
for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.length; }
|
||||
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); }
|
||||
catch { fail("load", "invalid language pack JSON"); }
|
||||
}
|
||||
50
i18n/browser/runtime.test.mjs
Normal file
50
i18n/browser/runtime.test.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLocalizer, createLanguageController, overlayPack, fetchPack } from "./runtime.mjs";
|
||||
|
||||
const pack = locale => ({ version: 1, locale, ruleVersion: "cldr-46.1", pluralRules: { en: { one: "i = 1 and v = 0" }, fr: { one: "i = 0,1" }, de: { one: "i = 1 and v = 0" } }, config: { sourceLocale: "en", defaultLocale: "en", locales: ["en", "fr", "de"] }, documents: [
|
||||
{ version: 1, locale: "en", namespace: "ui", messages: { title: { value: { text: "English" } }, greet: { params: { name: "string" }, value: { text: "Hello {name}" } } } },
|
||||
... (locale === "en" ? [] : [{ version: 1, locale, namespace: "ui", messages: { title: { value: { text: locale } } } }])
|
||||
] });
|
||||
|
||||
test("detached resources, explicit overrides, text arguments and fallback", () => {
|
||||
const data = pack("fr"), localizer = createLocalizer(data);
|
||||
data.documents[0].messages.greet.value.text = "changed";
|
||||
assert.equal(localizer.t("ui", "greet", { name: "<b>{name}</b>" }), "Hello <b>{name}</b>");
|
||||
assert.equal(localizer.render("ui", "greet", { name: "x" }).locale, "en");
|
||||
const original = pack("en");
|
||||
const overridden = overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { title: { value: { text: "Custom" } } } }]);
|
||||
assert.equal(createLocalizer(overridden).t("ui", "title"), "Custom");
|
||||
assert.equal(createLocalizer(original).t("ui", "title"), "English");
|
||||
assert.throws(() => overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { unknown: { value: {} } } }]));
|
||||
});
|
||||
|
||||
test("latest selection wins and failures preserve committed state", async () => {
|
||||
const pending = new Map();
|
||||
const controller = createLanguageController(pack("en"), locale => new Promise((resolve, reject) => pending.set(locale, { resolve, reject })));
|
||||
const french = controller.setLanguage("fr"), german = controller.setLanguage("de");
|
||||
pending.get("de").resolve(pack("de")); assert.equal(await german, true);
|
||||
pending.get("fr").resolve(pack("fr")); assert.equal(await french, false);
|
||||
assert.equal(controller.current.locale, "de");
|
||||
const failed = controller.setLanguage("fr"); pending.get("fr").reject(Error("offline"));
|
||||
await assert.rejects(failed); assert.equal(controller.current.locale, "de");
|
||||
const stale = controller.setLanguage("fr"); await controller.setLanguage("de");
|
||||
pending.get("fr").reject(Error("stale failure")); assert.equal(await stale, false);
|
||||
assert.equal(controller.current.t("ui", "title"), "de");
|
||||
});
|
||||
|
||||
test("bad packs cannot commit or change enabled languages/schemas", async () => {
|
||||
const controller = createLanguageController(pack("en"), async () => { const data = pack("fr"); delete data.documents[0].messages.greet; return data; });
|
||||
await assert.rejects(controller.setLanguage("fr")); assert.equal(controller.current.locale, "en");
|
||||
await assert.rejects(controller.setLanguage("zz"));
|
||||
const cycle = pack("en"); cycle.config.fallbacks = { en: ["fr"], fr: ["en"] }; assert.throws(() => createLocalizer(cycle));
|
||||
const duplicate = pack("en"); duplicate.documents.push(duplicate.documents[0]); assert.throws(() => createLocalizer(duplicate));
|
||||
});
|
||||
|
||||
test("bounded fetching and validation before switching", async () => {
|
||||
const data = await fetchPack("/en.json", { fetch: async () => new Response(JSON.stringify(pack("en"))) });
|
||||
assert.equal(createLocalizer(data).locale, "en");
|
||||
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad", { status: 500 }) }));
|
||||
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad") }));
|
||||
await assert.rejects(fetchPack("/huge", { fetch: async () => new Response(new Uint8Array(4194305)) }));
|
||||
});
|
||||
26
i18n/browser/testdata/consumer.mts
vendored
Normal file
26
i18n/browser/testdata/consumer.mts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
import {createLocalizer, createLanguageController, fetchPack, overlayPack} from './runtime.mjs';
|
||||
import type {Pack} from './runtime.mjs';
|
||||
import type {Translate} from './messages.generated.mjs';
|
||||
|
||||
declare const pack: Pack;
|
||||
const messages = createLocalizer(pack);
|
||||
const t: Translate = messages.t;
|
||||
t('common', 'greeting', {first_name: 'Alex'});
|
||||
t('common', 'count', {count: 9223372036854775807n});
|
||||
t('common', 'count', {count: '9223372036854775807'});
|
||||
t('common', 'literal', {});
|
||||
// @ts-expect-error a message without parameters accepts only an empty object
|
||||
t('common', 'literal', {extra: 1});
|
||||
// @ts-expect-error missing parameter
|
||||
t('common', 'greeting', {});
|
||||
// @ts-expect-error invalid parameter type
|
||||
t('common', 'greeting', {first_name: false});
|
||||
// @ts-expect-error undeclared parameter
|
||||
t('common', 'greeting', {first_name: 'Alex', secret: 'extra'});
|
||||
// @ts-expect-error unknown key
|
||||
t('common', 'unknown', {});
|
||||
// @ts-expect-error unknown namespace
|
||||
t('missing', 'literal', {});
|
||||
const controller = createLanguageController(pack, locale => fetchPack('/' + locale + '.json'));
|
||||
void controller.setLanguage('en');
|
||||
overlayPack(pack, [{version: 1, locale: 'en', namespace: 'common', messages: {literal: {value: {text: 'Custom'}}}}]);
|
||||
434
i18n/bundle.go
Normal file
434
i18n/bundle.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
schema map[string]Kind
|
||||
patterns map[string]*compiled
|
||||
}
|
||||
|
||||
// Bundle owns detached, compiled resources and is safe for concurrent reads.
|
||||
// Reconfiguration constructs another Bundle; no global current language exists.
|
||||
type Bundle struct {
|
||||
config Config
|
||||
matcher language.Matcher
|
||||
locales []string
|
||||
tags map[string]language.Tag
|
||||
entries map[Ref]*entry
|
||||
documents []Document
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
func validID(s string) bool {
|
||||
return s != "" && len(s) <= 256 && utf8.ValidString(s) && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0
|
||||
}
|
||||
func canonical(s string) (string, error) {
|
||||
if s == "" || len(s) > 128 {
|
||||
return "", fmt.Errorf("i18n: language tag required (maximum 128 bytes)")
|
||||
}
|
||||
t, err := language.Parse(s)
|
||||
if err != nil || t == language.Und {
|
||||
return "", fmt.Errorf("i18n: invalid language tag")
|
||||
}
|
||||
return t.String(), nil
|
||||
}
|
||||
|
||||
func normalizeConfig(c Config) (Config, error) {
|
||||
var err error
|
||||
if c.SourceLocale, err = canonical(c.SourceLocale); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.DefaultLocale, err = canonical(c.DefaultLocale); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len(c.Locales) == 0 || len(c.Locales) > 64 {
|
||||
return Config{}, fmt.Errorf("i18n: enable between 1 and 64 locales")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
locales := make([]string, 0, len(c.Locales))
|
||||
for _, raw := range c.Locales {
|
||||
l, e := canonical(raw)
|
||||
if e != nil {
|
||||
return Config{}, e
|
||||
}
|
||||
if seen[l] {
|
||||
return Config{}, fmt.Errorf("i18n: duplicate normalized locale %s", l)
|
||||
}
|
||||
seen[l] = true
|
||||
locales = append(locales, l)
|
||||
}
|
||||
if !seen[c.DefaultLocale] {
|
||||
return Config{}, fmt.Errorf("i18n: default locale must be enabled")
|
||||
}
|
||||
c.Locales = locales
|
||||
fallbacks := map[string][]string{}
|
||||
for _, raw := range sortedKeys(c.Fallbacks) {
|
||||
l, e := canonical(raw)
|
||||
if e != nil || !seen[l] {
|
||||
return Config{}, fmt.Errorf("i18n: fallback locale must be enabled")
|
||||
}
|
||||
if _, ok := fallbacks[l]; ok {
|
||||
return Config{}, fmt.Errorf("i18n: duplicate normalized fallback locale")
|
||||
}
|
||||
if len(c.Fallbacks[raw]) > 64 {
|
||||
return Config{}, fmt.Errorf("i18n: too many fallback locales")
|
||||
}
|
||||
list := []string{}
|
||||
unique := map[string]bool{}
|
||||
for _, v := range c.Fallbacks[raw] {
|
||||
to, e := canonical(v)
|
||||
if e != nil || !seen[to] || unique[to] {
|
||||
return Config{}, fmt.Errorf("i18n: fallback targets must be distinct enabled locales")
|
||||
}
|
||||
unique[to] = true
|
||||
list = append(list, to)
|
||||
}
|
||||
fallbacks[l] = list
|
||||
}
|
||||
state := map[string]uint8{}
|
||||
var visit func(string) error
|
||||
visit = func(l string) error {
|
||||
if state[l] == 1 {
|
||||
return fmt.Errorf("i18n: fallback cycle")
|
||||
}
|
||||
if state[l] == 2 {
|
||||
return nil
|
||||
}
|
||||
state[l] = 1
|
||||
for _, to := range fallbacks[l] {
|
||||
if err := visit(to); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state[l] = 2
|
||||
return nil
|
||||
}
|
||||
for _, l := range locales {
|
||||
if err := visit(l); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
c.Fallbacks = fallbacks
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error { _, err := normalizeConfig(c); return err }
|
||||
|
||||
// New accepts split documents; duplicate (locale, namespace, key) definitions
|
||||
// are rejected. Use Overlay explicitly when replacement is intended.
|
||||
func New(config Config, documents ...Document) (*Bundle, error) {
|
||||
c, err := normalizeConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := &Bundle{config: c, tags: map[string]language.Tag{}, entries: map[Ref]*entry{}}
|
||||
b.locales = append([]string{c.DefaultLocale}, c.Locales...)
|
||||
b.locales = unique(b.locales)
|
||||
tags := make([]language.Tag, 0, len(b.locales))
|
||||
for _, l := range b.locales {
|
||||
t := language.MustParse(l)
|
||||
tags = append(tags, t)
|
||||
b.tags[l] = t
|
||||
}
|
||||
b.matcher = language.NewMatcher(tags)
|
||||
if len(documents) > 1024 {
|
||||
return nil, fmt.Errorf("i18n: too many resource documents")
|
||||
}
|
||||
groups := map[string]map[string]map[string]Message{}
|
||||
total := 0
|
||||
names := map[string]bool{}
|
||||
languages := map[string]bool{}
|
||||
for _, doc := range documents {
|
||||
l, e := canonical(doc.Locale)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if doc.Version != Version || !validID(doc.Namespace) {
|
||||
return nil, fmt.Errorf("i18n: version 1 and a valid namespace are required")
|
||||
}
|
||||
names[doc.Namespace] = true
|
||||
languages[l] = true
|
||||
if len(names) > 128 || len(languages) > 64 {
|
||||
return nil, fmt.Errorf("i18n: namespace or locale resource limit exceeded")
|
||||
}
|
||||
if groups[l] == nil {
|
||||
groups[l] = map[string]map[string]Message{}
|
||||
}
|
||||
if groups[l][doc.Namespace] == nil {
|
||||
groups[l][doc.Namespace] = map[string]Message{}
|
||||
}
|
||||
for _, k := range sortedKeys(doc.Messages) {
|
||||
if !validID(k) {
|
||||
return nil, fmt.Errorf("i18n: invalid message key")
|
||||
}
|
||||
if _, ok := groups[l][doc.Namespace][k]; ok {
|
||||
return nil, &Diagnostic{Kind: "duplicate", Namespace: doc.Namespace, Key: k, Locale: l, Problem: "use an explicit overlay to replace a message"}
|
||||
}
|
||||
total++
|
||||
if total > 10000 {
|
||||
return nil, fmt.Errorf("i18n: resource exceeds 10000 messages")
|
||||
}
|
||||
groups[l][doc.Namespace][k] = doc.Messages[k]
|
||||
}
|
||||
}
|
||||
for _, ns := range sortedKeys(groups[c.SourceLocale]) {
|
||||
for _, key := range sortedKeys(groups[c.SourceLocale][ns]) {
|
||||
m := groups[c.SourceLocale][ns][key]
|
||||
if len(m.Params) > 64 {
|
||||
return nil, fmt.Errorf("i18n: message exceeds 64 parameters")
|
||||
}
|
||||
schema := map[string]Kind{}
|
||||
for name, kind := range m.Params {
|
||||
if !paramName.MatchString(name) || len(name) > 128 {
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "invalid parameter name"}
|
||||
}
|
||||
switch kind {
|
||||
case String, Boolean, Integer, Number:
|
||||
default:
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "unsupported type"}
|
||||
}
|
||||
schema[name] = kind
|
||||
}
|
||||
b.entries[Ref{ns, key}] = &entry{schema: schema, patterns: map[string]*compiled{}}
|
||||
}
|
||||
}
|
||||
limits := &budget{}
|
||||
for _, l := range sortedKeys(groups) {
|
||||
for _, ns := range sortedKeys(groups[l]) {
|
||||
doc := Document{Version: Version, Locale: l, Namespace: ns, Messages: map[string]Message{}}
|
||||
for _, key := range sortedKeys(groups[l][ns]) {
|
||||
m := groups[l][ns][key]
|
||||
e := b.entries[Ref{ns, key}]
|
||||
if e == nil {
|
||||
return nil, &Diagnostic{Kind: "unknown", Namespace: ns, Key: key, Locale: l, Problem: "message has no source definition"}
|
||||
}
|
||||
if m.Params != nil && !sameSchema(m.Params, e.schema) {
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Locale: l, Problem: "translation parameter schema differs from source"}
|
||||
}
|
||||
used := map[string]bool{}
|
||||
p, err := compile(m.Value, e.schema, used, limits, 0)
|
||||
if err == nil {
|
||||
for _, name := range sortedKeys(e.schema) {
|
||||
if !used[name] {
|
||||
err = &Diagnostic{Kind: "parameter", Param: name, Problem: "declared parameter is unused in this message"}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
d := *err.(*Diagnostic)
|
||||
d.Namespace, d.Key, d.Locale = ns, key, l
|
||||
return nil, &d
|
||||
}
|
||||
e.patterns[l] = p
|
||||
doc.Messages[key] = Message{Params: cloneSchema(e.schema), Value: clonePattern(m.Value)}
|
||||
}
|
||||
b.documents = append(b.documents, doc)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func unique(in []string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func sameSchema(a, b map[string]Kind) bool {
|
||||
return len(a) == len(b) && reflect.DeepEqual(cloneSchema(a), cloneSchema(b))
|
||||
}
|
||||
func cloneSchema(m map[string]Kind) map[string]Kind {
|
||||
out := map[string]Kind{}
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
func clonePattern(p Pattern) Pattern {
|
||||
if p.Cases != nil {
|
||||
m := make(map[string]Pattern, len(p.Cases))
|
||||
for k, v := range p.Cases {
|
||||
m[k] = clonePattern(v)
|
||||
}
|
||||
p.Cases = m
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (b *Bundle) Config() Config {
|
||||
if b == nil {
|
||||
return Config{}
|
||||
}
|
||||
c := b.config
|
||||
c.Locales = append([]string(nil), c.Locales...)
|
||||
c.Fallbacks = map[string][]string{}
|
||||
for k, v := range b.config.Fallbacks {
|
||||
c.Fallbacks[k] = append([]string{}, v...)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Match accepts ordered language tags, not an HTTP header. It returns an enabled
|
||||
// dictionary tag (not a synthetic matcher tag with user extensions).
|
||||
func (b *Bundle) Match(preferences ...string) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
tags := make([]language.Tag, 0, len(preferences))
|
||||
for _, s := range preferences {
|
||||
if len(tags) >= 64 {
|
||||
break
|
||||
}
|
||||
if len(s) > 128 {
|
||||
continue
|
||||
}
|
||||
if t, e := language.Parse(s); s != "" && e == nil {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
_, index, _ := b.matcher.Match(tags...)
|
||||
return b.locales[index]
|
||||
}
|
||||
|
||||
func (b *Bundle) chain(locale string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
var add func(string)
|
||||
add = func(l string) {
|
||||
if seen[l] {
|
||||
return
|
||||
}
|
||||
seen[l] = true
|
||||
out = append(out, l)
|
||||
for _, to := range b.config.Fallbacks[l] {
|
||||
add(to)
|
||||
}
|
||||
}
|
||||
add(locale)
|
||||
add(b.config.DefaultLocale)
|
||||
add(b.config.SourceLocale)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Bundle) Render(locale string, ref Ref, params map[string]any) (Result, error) {
|
||||
if b == nil {
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Problem: "no bundle"}
|
||||
}
|
||||
requested := b.Match(locale)
|
||||
e := b.entries[ref]
|
||||
if e == nil {
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "unknown message"}
|
||||
}
|
||||
values := make(map[string]string, len(e.schema))
|
||||
for _, k := range sortedKeys(e.schema) {
|
||||
v, ok := params[k]
|
||||
if !ok {
|
||||
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "missing argument"}
|
||||
}
|
||||
s, err := scalar(e.schema[k], v)
|
||||
if err != nil {
|
||||
d := *err.(*Diagnostic)
|
||||
d.Namespace, d.Key, d.Param = ref.Namespace, ref.Key, k
|
||||
return Result{}, &d
|
||||
}
|
||||
values[k] = s
|
||||
}
|
||||
for _, k := range sortedKeys(params) {
|
||||
if _, ok := e.schema[k]; !ok {
|
||||
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "undeclared argument"}
|
||||
}
|
||||
}
|
||||
for _, l := range b.chain(requested) {
|
||||
if p := e.patterns[l]; p != nil {
|
||||
text, err := p.render(language.MustParse(l), values)
|
||||
return Result{Text: text, Requested: requested, Locale: l, Fallback: l != requested}, err
|
||||
}
|
||||
}
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "no message in fallback chain"}
|
||||
}
|
||||
func (b *Bundle) RenderContext(ctx context.Context, ref Ref, params map[string]any) (Result, error) {
|
||||
return b.Render(Locale(ctx), ref, params)
|
||||
}
|
||||
|
||||
// Schema returns a detached source schema for adapters and static tools.
|
||||
func (b *Bundle) Schema(ref Ref) (map[string]Kind, bool) {
|
||||
if b == nil {
|
||||
return nil, false
|
||||
}
|
||||
e := b.entries[ref]
|
||||
if e == nil {
|
||||
return nil, false
|
||||
}
|
||||
return cloneSchema(e.schema), true
|
||||
}
|
||||
|
||||
// Check reports missing direct translations. Runtime fallback remains usable;
|
||||
// build tooling can reject these diagnostics without changing runtime behavior.
|
||||
func (b *Bundle) Check() []Diagnostic {
|
||||
out := []Diagnostic{}
|
||||
if b == nil {
|
||||
return out
|
||||
}
|
||||
for _, d := range b.documents {
|
||||
if d.Locale != b.config.SourceLocale {
|
||||
continue
|
||||
}
|
||||
for _, key := range sortedKeys(d.Messages) {
|
||||
e := b.entries[Ref{d.Namespace, key}]
|
||||
for _, l := range b.config.Locales {
|
||||
if e.patterns[l] == nil {
|
||||
out = append(out, Diagnostic{Kind: "missing", Namespace: d.Namespace, Key: key, Locale: l, Problem: "direct translation missing"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Documents returns detached resources. An empty namespace filter includes all;
|
||||
// exporting for a browser should normally supply explicit namespaces.
|
||||
func (b *Bundle) Documents(locale string, namespaces ...string) []Document {
|
||||
out := []Document{}
|
||||
if b == nil {
|
||||
return out
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
for _, ns := range namespaces {
|
||||
wanted[ns] = true
|
||||
}
|
||||
for _, d := range b.documents {
|
||||
if locale != "" && d.Locale != locale || len(wanted) > 0 && !wanted[d.Namespace] {
|
||||
continue
|
||||
}
|
||||
copy := d
|
||||
copy.Messages = map[string]Message{}
|
||||
for k, m := range d.Messages {
|
||||
copy.Messages[k] = Message{Params: cloneSchema(m.Params), Value: clonePattern(m.Value)}
|
||||
}
|
||||
out = append(out, copy)
|
||||
}
|
||||
return out
|
||||
}
|
||||
353
i18n/bundle_test.go
Normal file
353
i18n/bundle_test.go
Normal 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
41
i18n/cldr/LICENSE
Normal 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
19
i18n/cldr/README.md
Normal 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
933
i18n/cldr/plurals.json
Normal 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
151
i18n/errori18n/errors.go
Normal 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
|
||||
}
|
||||
54
i18n/errori18n/errors_test.go
Normal file
54
i18n/errori18n/errors_test.go
Normal 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
Reference in New Issue
Block a user