Compare commits
4 Commits
v0.25.0-pr
...
v0.27.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b00bb55d1 | |||
| 859f1851b1 | |||
| d1a747c36f | |||
| 6a95cad184 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.27.0-preview(2026-09-29)
|
||||||
|
|
||||||
|
- HTTP 配置新增 defaultMaxBodyBytes、maxBodyBytes、shutdownCancelTimeout;三种内置服务统一默认/路由/硬请求体限额,配置冲突在监听前报告完整路由与限额。native/HTTP2 不再以隐含 8 MiB 阻挡已放宽的 App 上传路由。
|
||||||
|
- App 在所有服务入口跟踪完整请求处理链;关闭连接后有界等待处理器实际退出。超时仍有请求则返回 ErrResourcesRetained,跳过 App 清理;Runtime.Finish 继续停止后台工作但保留依赖,生成入口保留日志。
|
||||||
|
- native 提前拒绝上传后有界半关闭连接,减少 TCP reset 吞掉错误响应;业务 HTTP 200 策略不再触发对被拒绝请求体的继续读取。
|
||||||
|
- HTTP/2 Shutdown 同时等待连接和独立处理器,修复客户端断开后误判完成。增加真实 TLS/流式上传、取消收尾、拒绝晚到请求、资源保留和生成消费者回归,以及 httpSafety 示例。
|
||||||
|
- 迁移:nethttp 手写普通路由新增默认 8 MiB 请求体上限,大请求显式覆盖;生成契约默认 1 MiB 保持。HTTP 停机多一个默认 5s 取消收尾窗口;外部宿主须将运行错误传给 Runtime.Finish,并遵守资源保留信号。
|
||||||
|
|
||||||
|
## v0.26.0-preview(2026-09-29)
|
||||||
|
|
||||||
|
- 新增独立 i18n:不可变命名空间词典、源/默认/启用语言、BCP 47 匹配、显式回退、参数/条件/基数复数和有界资源加载;不依赖 HTTP、业务或数据库。
|
||||||
|
- Go/浏览器共用固定 CLDR 46.1 复数规则;JSON 资源版本、类型导出和按命名空间选择的语言包独立于业务错误格式。
|
||||||
|
- 新增可选 httpi18n/errori18n 适配;错误 translation 元数据、PublicMessage 展示入口,保留原业务码/状态/失败语义/原始原因和首次幂等响应。
|
||||||
|
- 新增 i18n check/export/types/extract;静态与动态键诊断、未知字段/重复定义/参数校验、显式覆盖;输入只读,不执行任意项目 Go。
|
||||||
|
- 新增无前端框架依赖的浏览器模块、按需加载/切换竞态/失败保留、CLI/HTTP/表单示例及通用验收。旧项目默认行为不变。
|
||||||
|
- 新 translation 生成代码要求 v0.26.0-preview 或具备能力的本地源码,未使用该元数据的模板最低版本保持 v0.22。
|
||||||
|
|
||||||
|
- 修复 JSON 对象/数组结束定界符读取失败时丢失原始错误的问题,保留请求体限额的 HTTP 413;共享 Go 目录测试工程改用声明支持的 Go 1.22。
|
||||||
|
|
||||||
## v0.25.0-preview(2026-09-26)
|
## v0.25.0-preview(2026-09-26)
|
||||||
|
|
||||||
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
|
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量
|
|||||||
|
|
||||||
低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。
|
低层 `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)。
|
生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 通用业务开发指南(v0.25.0-preview)
|
# 通用业务开发指南(v0.27.0-preview)
|
||||||
|
|
||||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 生成项目与文件维护边界
|
# 生成项目与文件维护边界
|
||||||
|
|
||||||
v0.25.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
v0.27.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||||
|
|
||||||
## 创建和日常开发
|
## 创建和日常开发
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
|
|
||||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||||
|
|
||||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后取消请求并关闭连接,再按 `http.shutdownCancelTimeout` 等待处理函数收尾。仍未结束时保留依赖并返回 `ErrResourcesRetained`,不会提前关闭数据库。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||||
|
|
||||||
## 依赖主动申请退出
|
## 依赖主动申请退出
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
c.RequestShutdown(err)
|
c.RequestShutdown(err)
|
||||||
```
|
```
|
||||||
|
|
||||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再等待 HTTP 取消收尾窗口。通过 `svc.Finish(runErr)` 执行后台停机;确认工作结束才关闭依赖与日志,无法确认时保留资源并非零退出。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||||
|
|
||||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ get /download (DownloadReq)
|
|||||||
|
|
||||||
## 已有服务接入步骤
|
## 已有服务接入步骤
|
||||||
|
|
||||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.25.0-preview:
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.27.0-preview:
|
||||||
|
|
||||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.25.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
|
||||||
|
|
||||||
|
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.27.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
|
||||||
|
|
||||||
|
**从这份 README 开始接入。** 当前版本 **`v0.27.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
|
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
|||||||
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.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) |
|
| 公共响应与特殊协议 | `.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) |
|
| 错误目录与动态提示 | `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) |
|
| 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#配置与日志热更新) |
|
| 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
|
||||||
@@ -153,7 +158,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -310,7 +315,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.27.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -586,6 +591,9 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
|
|||||||
| `server.http.readHeaderTimeout/readTimeout` | 请求头 / 请求读取超时 |
|
| `server.http.readHeaderTimeout/readTimeout` | 请求头 / 请求读取超时 |
|
||||||
| `server.http.writeTimeout/idleTimeout` | 响应写入 / Keep-Alive 空闲超时 |
|
| `server.http.writeTimeout/idleTimeout` | 响应写入 / Keep-Alive 空闲超时 |
|
||||||
| `server.http.maxHeaderBytes` | 请求头大小限制,字节数 |
|
| `server.http.maxHeaderBytes` | 请求头大小限制,字节数 |
|
||||||
|
| `server.http.defaultMaxBodyBytes` | 普通 App 路由默认请求体上限,零默认 8 MiB;路由可覆盖 |
|
||||||
|
| `server.http.maxBodyBytes` | 服务请求体硬上限,路由不得超过;零不添加低于框架 1 TiB 的硬上限 |
|
||||||
|
| `server.http.shutdownCancelTimeout` | 请求取消后等待处理函数收尾,零默认 5s;未结束则保留资源 |
|
||||||
| `server.shutdownTimeout` | 优雅退出等待时间,默认 10s |
|
| `server.shutdownTimeout` | 优雅退出等待时间,默认 10s |
|
||||||
| `server.enableLogger` / `server.log.level` | 访问日志开关、日志级别 |
|
| `server.enableLogger` / `server.log.level` | 访问日志开关、日志级别 |
|
||||||
| `server.trustedProxies` | 可信代理 IP/CIDR,文件数组 |
|
| `server.trustedProxies` | 可信代理 IP/CIDR,文件数组 |
|
||||||
@@ -613,13 +621,15 @@ server:
|
|||||||
maxHeaderBytes: 1048576
|
maxHeaderBytes: 1048576
|
||||||
```
|
```
|
||||||
|
|
||||||
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**正值覆盖、零值保留所选网络层的原有默认行为、负值报错**;不配置时升级行为不变。超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
|
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**网络 I/O 参数正值覆盖、零值保留所选网络层的原有默认行为、负值报错**。请求体与取消收尾字段按上表解释;网络超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
|
||||||
|
|
||||||
|
外部宿主在注册完成后可用 `app.ValidateHTTP()` 预检配置与路由限额。上传路由使用 `app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, handler)` 或 `.api @request(maxBytes: 16777216)`;限额涵盖整个请求体。配置硬上限时至少允许该值;multipart 单文件、字段数以及幂等中间件缓存限制仍独立配置。新增普通路由默认 8 MiB;原 nethttp 手写大请求请显式设置默认值或路由上限,生成路由既有 `maxBytes`(默认 1 MiB)保持。
|
||||||
|
|
||||||
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
||||||
|
|
||||||
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
|
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
|
||||||
|
|
||||||
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
|
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。先停止接收新请求并等待排空,超时取消请求、关闭连接,再等待 `http.shutdownCancelTimeout` 收尾窗口。只有处理函数和它的 defer 全部退出才清理资源;仍有请求时返回 `lifecycle.ErrResourcesRetained`,跳过 App 清理钩子。生成入口通过 `deps.Finish(runErr)` 同步保留数据库等依赖及日志,非零退出。`App.Close`、`Engine`、`NewServer`、`RunWith` 共用这个完成屏障;业务自行启动的 goroutine、Hijack 后的工作需另行注册后台生命周期。
|
||||||
|
|
||||||
## 日常开发与升级
|
## 日常开发与升级
|
||||||
|
|
||||||
@@ -636,7 +646,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#契约变更提示)。
|
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||||
|
|
||||||
CLI 与业务 module 固定使用 `v0.25.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.27.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
|
|||||||
44
RELEASE.md
44
RELEASE.md
@@ -1,3 +1,45 @@
|
|||||||
|
# v0.27.0-preview
|
||||||
|
|
||||||
|
新增统一请求体策略与请求完成屏障,无业务字段、数据库依赖或新第三方包。`server.http.defaultMaxBodyBytes` 控制普通路由默认值(零为 8 MiB),`maxBodyBytes` 为不可由路由突破的服务硬上限(零仅保留 1 TiB 框架上界)。已有显式路由和生成契约限额保持,冲突会在启动前报错;旧 nethttp 手写大请求需显式设置路由或默认上限。multipart 单文件、中间件缓存及代理限额仍各自配置。
|
||||||
|
|
||||||
|
HTTP 关闭连接后继续等待处理器实际退出,收尾窗口由 `server.http.shutdownCancelTimeout` 设置(零默认 5s)。超时仍有处理器运行时返回 `lifecycle.ErrResourcesRetained`,App/Runtime/生成入口保留共享依赖和日志;不再将连接关闭当成业务完成。`Runtime.Finish(runErr)` 会传播该信号,手写入口必须处理它,避免无条件关闭额外资源。该行为适用于全部 App 服务入口;HTTP/2 低层 Shutdown 同时等待独立处理器。
|
||||||
|
|
||||||
|
详见 [请求体策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)、[运行示例](examples/httpSafety/README.md)。CLI 与框架固定使用同版 v0.27.0-preview;模板最低兼容版本仍为 v0.22,HTTP 安全改进需要升级运行时。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.27.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
54 个测试包全量 race、Go 1.22 兼容检查、生成项目集成测试、vet/build 及真实进程关闭检查通过,见 [验收记录](reports/httpSafetyAcceptance.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.26.0-preview
|
||||||
|
|
||||||
|
新增可独立使用的 i18n 底层模块及可选 HTTP/错误适配。项目维护文案和语言设置,框架提供不可变词典、语言匹配、回退、参数与分支、检查和导出;没有绑定业务身份、页面框架、数据库或响应字段名。
|
||||||
|
|
||||||
|
前后端共享版本化消息资源和固定 Unicode CLDR 46.1 基数复数规则。CLI 只读输入,支持源目录提取、缺译/参数/动态引用检查、按命名空间导出语言包和 TypeScript 类型。可选浏览器模块先加载并验证再切换,失败保留当前语言,旧请求不能覆盖新选择。
|
||||||
|
|
||||||
|
错误目录新增可选 `translation` 引用。内置响应使用展示译文,原目录文案继续参与契约校验;自定义 Encoder 通过 `PublicMessage()` 显式采用译文。业务码、HTTP 状态、成功/失败语义、cause、直接协议输出和幂等首次快照保持。旧配置与生成项目不启用时无需迁移。
|
||||||
|
|
||||||
|
完整 [使用指南](i18n/README.md)、[通用示例](examples/i18n/README.md)、[验收记录](reports/i18nAcceptance.md)。包含 translation 的新契约远程依赖最低版本为 v0.26.0-preview;未使用该元数据的模板最低版本仍为 v0.22。CLI 与框架使用同版。
|
||||||
|
|
||||||
|
同时修复 JSON 结束定界符读取失败时丢失原始错误的问题,使请求体超过外层限制时在 Go 1.22 中也保持 HTTP 413;共享目录测试工程使用 Go 1.22。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
发布前 54 个测试包全量 race 与 Go 1.22(含生成消费者)、vet/build、真实进程检查通过。空缓存安装远程固定版本后,10 组生成消费者和 87 项发布检查通过,耗时 83.76 秒,无本地 replace;包含下载模块的国际化、CLDR/浏览器一致性、TypeScript 类型和 CLI 工具链验证。标签固定在 `d1a747c36f15d964dd445d661623b68a45892adf`,[远程验收记录](reports/i18nRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v0.25.0-preview
|
# v0.25.0-preview
|
||||||
|
|
||||||
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
|
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
|
||||||
@@ -18,6 +60,8 @@ go test ./...
|
|||||||
|
|
||||||
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
|
[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(历史版本)
|
# v0.24.0-preview(历史版本)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 公共响应体与独立接口协议
|
# 公共响应体与独立接口协议
|
||||||
|
|
||||||
|
v0.26 新增的 [国际化适配](i18n/README.md#接入-go-omni-错误与-http)保留现有响应契约。`Result.Message` 是校验使用的目录原文;`MessageOverride` 是可选展示译文,`PublicMessage()` 返回最终文案。内置 Formatter 自动使用译文;自定义 Encoder 显式选择,固定文本/XML 回执和直接输出不自动改写。
|
||||||
|
|
||||||
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
|
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
|
||||||
|
|
||||||
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
|
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
|
||||||
|
|||||||
69
app/app.go
69
app/app.go
@@ -33,6 +33,10 @@ type App struct {
|
|||||||
closeErr error
|
closeErr error
|
||||||
clientIPResolver *httpx.ClientIPResolver
|
clientIPResolver *httpx.ClientIPResolver
|
||||||
responseEncoder response.Encoder
|
responseEncoder response.Encoder
|
||||||
|
requests requestDrain
|
||||||
|
bodyLimits []routeBodyLimit
|
||||||
|
validateOnce sync.Once
|
||||||
|
validateErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner is the small lifecycle boundary used by an HTTP server
|
// Runner is the small lifecycle boundary used by an HTTP server
|
||||||
@@ -100,7 +104,14 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
|||||||
}
|
}
|
||||||
middlewares = append(middlewares, middleware.Recovery())
|
middlewares = append(middlewares, middleware.Recovery())
|
||||||
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
|
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
|
||||||
application.handler = corsPolicy.Wrap(router)
|
application.requests.done = make(chan struct{})
|
||||||
|
application.handler = corsPolicy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.ContentLength > application.hardBodyLimit() {
|
||||||
|
application.adapt(func(*httpx.Context) error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
if fallback, ok := router.(httpx.FallbackRouter); ok {
|
if fallback, ok := router.(httpx.FallbackRouter); ok {
|
||||||
adaptError := func(status int, code, message string) http.Handler {
|
adaptError := func(status int, code, message string) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -113,15 +124,31 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Router exposes the underlying routing contract for advanced registration.
|
// Router exposes the underlying routing contract for advanced registration.
|
||||||
// Serve requests through App or Engine so application CORS policy is applied.
|
// Serve through App or Engine for CORS, hard body limits and shutdown tracking.
|
||||||
func (a *App) Router() httpx.Router { return a.router }
|
func (a *App) Router() httpx.Router { return a.router }
|
||||||
|
|
||||||
// Engine is kept as a compatibility name for the old API. It returns a
|
// 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.
|
// standard http.Handler, so callers do not depend on a router type.
|
||||||
func (a *App) Engine() http.Handler { return a.handler }
|
func (a *App) Engine() http.Handler { return a }
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
||||||
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
ctx, work := a.requests.begin(request.Context())
|
||||||
|
if work == nil {
|
||||||
|
// Do not invoke encoders, middleware or loggers after their owners close.
|
||||||
|
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer a.requests.end(work)
|
||||||
|
a.validateOnce.Do(func() { a.validateErr = a.ValidateHTTP() })
|
||||||
|
if a.validateErr != nil {
|
||||||
|
http.Error(writer, "invalid HTTP configuration", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request = request.WithContext(ctx)
|
||||||
|
if request.Body != nil {
|
||||||
|
request.Body = http.MaxBytesReader(writer, request.Body, a.hardBodyLimit())
|
||||||
|
}
|
||||||
a.handler.ServeHTTP(writer, request)
|
a.handler.ServeHTTP(writer, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +170,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OnShutdown registers a cleanup function owned by the application. Cleanup
|
// OnShutdown registers a cleanup function owned by the application. Cleanup
|
||||||
// functions run in reverse registration order when Close or RunContext ends.
|
// functions run in reverse registration order after all handlers have returned.
|
||||||
|
// Incomplete draining skips these callbacks and returns ErrResourcesRetained.
|
||||||
// This keeps omni.New as the only public startup entry point while allowing
|
// This keeps omni.New as the only public startup entry point while allowing
|
||||||
// projects to attach databases, loggers and other resources to its lifecycle.
|
// projects to attach databases, loggers and other resources to its lifecycle.
|
||||||
func (a *App) OnShutdown(cleanup func() error) *App {
|
func (a *App) OnShutdown(cleanup func() error) *App {
|
||||||
@@ -153,9 +181,21 @@ func (a *App) OnShutdown(cleanup func() error) *App {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases application resources. It is safe to call more than once.
|
// Close stops admission, cancels active requests and waits for their handlers
|
||||||
|
// to return before releasing resources. It is idempotent. If cancellation grace
|
||||||
|
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
|
||||||
|
// Use RunContext/ServeContext for graceful draining before cancellation. Do not
|
||||||
|
// call Close from a handler or cleanup callback; it would wait for itself.
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
a.closeOnce.Do(func() {
|
a.closeOnce.Do(func() {
|
||||||
|
timeout := a.config.HTTP.ShutdownCancelTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if err := a.requests.cancelAndWait(timeout); err != nil {
|
||||||
|
a.closeErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
for index := len(a.closers) - 1; index >= 0; index-- {
|
for index := len(a.closers) - 1; index >= 0; index-- {
|
||||||
if err := a.closers[index](); err != nil {
|
if err := a.closers[index](); err != nil {
|
||||||
a.closeErr = errors.Join(a.closeErr, err)
|
a.closeErr = errors.Join(a.closeErr, err)
|
||||||
@@ -177,7 +217,7 @@ func (a *App) Run(address ...string) error {
|
|||||||
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
|
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
|
||||||
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
||||||
func (a *App) NewServer(address string) *http.Server {
|
func (a *App) NewServer(address string) *http.Server {
|
||||||
if err := a.config.HTTP.Validate(); err != nil {
|
if err := a.ValidateHTTP(); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if address == "" {
|
if address == "" {
|
||||||
@@ -187,7 +227,7 @@ func (a *App) NewServer(address string) *http.Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg := a.config.HTTP
|
cfg := a.config.HTTP
|
||||||
return &http.Server{Addr: address, Handler: a.handler,
|
return &http.Server{Addr: address, Handler: a,
|
||||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
||||||
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
||||||
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
||||||
@@ -202,6 +242,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) (runErr error)
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return a.Close()
|
return a.Close()
|
||||||
}
|
}
|
||||||
|
stopWatching := a.watchShutdown(ctx)
|
||||||
|
defer stopWatching()
|
||||||
serve, err := a.prepareTransport()
|
serve, err := a.prepareTransport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Join(err, a.Close())
|
return errors.Join(err, a.Close())
|
||||||
@@ -228,6 +270,11 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (ru
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return a.Close()
|
return a.Close()
|
||||||
}
|
}
|
||||||
|
if err := a.ValidateHTTP(); err != nil {
|
||||||
|
return errors.Join(err, a.Close())
|
||||||
|
}
|
||||||
|
stopWatching := a.watchShutdown(ctx)
|
||||||
|
defer stopWatching()
|
||||||
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,12 +308,16 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-serveErr:
|
case err := <-serveErr:
|
||||||
|
// Serve returning only proves the accept loop ended. Cancel any remaining
|
||||||
|
// connections before the application completion barrier and cleanup.
|
||||||
|
_ = server.Close()
|
||||||
closeErr := a.Close()
|
closeErr := a.Close()
|
||||||
if err == http.ErrServerClosed {
|
if err == http.ErrServerClosed {
|
||||||
return closeErr
|
return closeErr
|
||||||
}
|
}
|
||||||
return errors.Join(err, closeErr)
|
return errors.Join(err, closeErr)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
a.requests.stop()
|
||||||
shutdownTimeout := a.config.ShutdownTimeout
|
shutdownTimeout := a.config.ShutdownTimeout
|
||||||
if shutdownTimeout <= 0 {
|
if shutdownTimeout <= 0 {
|
||||||
shutdownTimeout = 10 * time.Second
|
shutdownTimeout = 10 * time.Second
|
||||||
@@ -275,8 +326,8 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
shutdownErr := server.Shutdown(shutdownCtx)
|
shutdownErr := server.Shutdown(shutdownCtx)
|
||||||
if shutdownErr != nil {
|
if shutdownErr != nil {
|
||||||
// Shutdown leaves active connections open on timeout. Close them so
|
// Closing sockets requests cancellation; App.Close separately waits
|
||||||
// request contexts are canceled before releasing shared resources.
|
// for the actual handler return before releasing shared resources.
|
||||||
shutdownErr = errors.Join(shutdownErr, server.Close())
|
shutdownErr = errors.Join(shutdownErr, server.Close())
|
||||||
}
|
}
|
||||||
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
||||||
|
|||||||
63
app/body.go
63
app/body.go
@@ -1,11 +1,68 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"fmt"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type routeBodyLimit struct {
|
||||||
|
method, path string
|
||||||
|
maxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) hardBodyLimit() int64 {
|
||||||
|
if a.config.HTTP.MaxBodyBytes > 0 {
|
||||||
|
return a.config.HTTP.MaxBodyBytes
|
||||||
|
}
|
||||||
|
return httplimits.MaxBodyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) defaultBodyLimit() int64 {
|
||||||
|
limit := a.config.HTTP.DefaultMaxBodyBytes
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = httplimits.DefaultBodyBytes
|
||||||
|
}
|
||||||
|
if limit > a.hardBodyLimit() {
|
||||||
|
limit = a.hardBodyLimit()
|
||||||
|
}
|
||||||
|
// Invalid configuration is rejected at startup or the first external request.
|
||||||
|
if limit > httplimits.MaxBodyBytes {
|
||||||
|
limit = httplimits.MaxBodyBytes
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateHTTP checks HTTP settings and registered route body limits without
|
||||||
|
// opening a listener. Call after registration when hosting App/Engine externally.
|
||||||
|
// Built-in serving entry points and RunWith perform this check automatically.
|
||||||
|
func (a *App) ValidateHTTP() error {
|
||||||
|
if err := a.config.HTTP.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, route := range a.bodyLimits {
|
||||||
|
if route.maxBytes > a.hardBodyLimit() {
|
||||||
|
return &config.ConfigError{Field: "http.maxBodyBytes", Message: fmt.Sprintf("route %s %s maxBytes=%d exceeds service hard limit=%d; raise http.maxBodyBytes or lower the route limit", route.method, route.path, route.maxBytes, a.hardBodyLimit())}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinRoutePath(prefix, path string) string {
|
||||||
|
if path == "" {
|
||||||
|
if prefix == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||||
checked := httpx.LimitBody(maxBytes)(handler)
|
checked := httpx.LimitBody(maxBytes)(handler)
|
||||||
if encoder == nil {
|
if encoder == nil {
|
||||||
@@ -25,8 +82,10 @@ func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler http
|
|||||||
// the response policy, including auth failures and parameter errors.
|
// the response policy, including auth failures and parameter errors.
|
||||||
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
|
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
|
||||||
|
a.bodyLimits = append(a.bodyLimits, routeBodyLimit{method, path, maxBytes})
|
||||||
}
|
}
|
||||||
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
|
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
|
||||||
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
|
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
|
||||||
|
g.app.bodyLimits = append(g.app.bodyLimits, routeBodyLimit{method, joinRoutePath(g.prefix, path), maxBytes})
|
||||||
}
|
}
|
||||||
|
|||||||
224
app/bodyPolicy_test.go
Normal file
224
app/bodyPolicy_test.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveBodyPolicy(t *testing.T, a *App, transport string) (string, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
cert, key, clientTLS := testutil.CertificateFiles(t)
|
||||||
|
a.config.Transport = transport
|
||||||
|
a.config.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- a.ServeContext(ctx, l) }()
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS, ForceAttemptHTTP2: transport == config.TransportHTTP2}}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
client.CloseIdleConnections()
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("server did not stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return "https://" + l.Addr().String(), client
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeBody(c *httpx.Context) error {
|
||||||
|
_, err := io.Copy(io.Discard, c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Text(200, "accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPolicyAcrossTransports(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
a := New()
|
||||||
|
a.POST("/default", consumeBody)
|
||||||
|
a.HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||||
|
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||||
|
base, client := serveBodyPolicy(t, a, transport)
|
||||||
|
payload := bytes.Repeat([]byte{'x'}, 9<<20)
|
||||||
|
for _, chunked := range []bool{false, true} {
|
||||||
|
for _, path := range []string{"/default", "/large", "/v1/files/large"} {
|
||||||
|
var body io.Reader = bytes.NewReader(payload)
|
||||||
|
if chunked {
|
||||||
|
body = struct{ io.Reader }{body}
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("POST", base+path, body)
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := 200
|
||||||
|
if path == "/default" {
|
||||||
|
want = 413
|
||||||
|
}
|
||||||
|
if res.StatusCode != want || (want == 413 && !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||||
|
t.Fatalf("%s unknownLength=%v: %d %s", path, chunked, res.StatusCode, data)
|
||||||
|
}
|
||||||
|
if transport == config.TransportHTTP2 && res.ProtoMajor != 2 {
|
||||||
|
t.Fatal("HTTP/2 not negotiated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredBodyPolicyBeforeMiddleware(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 32, MaxBodyBytes: 128}))
|
||||||
|
var calls atomic.Int32
|
||||||
|
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error {
|
||||||
|
data, err := io.ReadAll(c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Request().Body = io.NopCloser(bytes.NewReader(data))
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
h := func(c *httpx.Context) error { calls.Add(1); return consumeBody(c) }
|
||||||
|
a.POST("/default", h)
|
||||||
|
a.HandleWithBodyLimit("POST", "/override", 64, nil, h)
|
||||||
|
a.HandleWithBodyLimit("POST", "/hard", 128, nil, h)
|
||||||
|
base, client := serveBodyPolicy(t, a, transport)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
size, status int
|
||||||
|
}{
|
||||||
|
{"/default", 32, 200}, {"/default", 33, 413}, {"/override", 64, 200}, {"/override", 65, 413}, {"/hard", 128, 200}, {"/hard", 129, 413},
|
||||||
|
} {
|
||||||
|
for _, unknown := range []bool{false, true} {
|
||||||
|
before := calls.Load()
|
||||||
|
var body io.Reader = strings.NewReader(strings.Repeat("a", tc.size))
|
||||||
|
if unknown {
|
||||||
|
body = struct{ io.Reader }{body}
|
||||||
|
}
|
||||||
|
res, err := client.Post(base+tc.path, "application/octet-stream", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil || res.StatusCode != tc.status {
|
||||||
|
t.Fatalf("%+v unknown=%v: %d %s %v", tc, unknown, res.StatusCode, data, err)
|
||||||
|
}
|
||||||
|
if tc.status == 413 && (calls.Load() != before || !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||||
|
t.Fatal("oversize reached business or lost error", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyHardLimitConflictBeforeServing(t *testing.T) {
|
||||||
|
for _, entry := range []string{"RunContext", "ServeContext", "RunWith", "NewServer", "Engine"} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{MaxBodyBytes: 8 << 20}))
|
||||||
|
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/upload/", 16<<20, nil, consumeBody)
|
||||||
|
closed := 0
|
||||||
|
a.OnShutdown(func() error { closed++; return nil })
|
||||||
|
if err := a.ValidateHTTP(); err == nil {
|
||||||
|
t.Fatal("external preflight accepted conflicting route limit")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch entry {
|
||||||
|
case "RunContext":
|
||||||
|
err = a.RunContext(context.Background(), "bad address")
|
||||||
|
case "ServeContext":
|
||||||
|
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
err = a.ServeContext(context.Background(), l)
|
||||||
|
if _, e = l.Accept(); !errors.Is(e, net.ErrClosed) {
|
||||||
|
t.Fatal("listener leaked", e)
|
||||||
|
}
|
||||||
|
case "RunWith":
|
||||||
|
err = a.RunWith(context.Background(), runnerFunc(func(context.Context, http.Handler, string) error { t.Error("runner started"); return nil }))
|
||||||
|
case "NewServer":
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
err, _ = p.(error)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
a.NewServer("")
|
||||||
|
}()
|
||||||
|
_ = a.Close()
|
||||||
|
case "Engine":
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.Engine().ServeHTTP(w, httptest.NewRequest("POST", "/v1/files/upload/", nil))
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
err = a.validateErr
|
||||||
|
_ = a.Close()
|
||||||
|
}
|
||||||
|
for _, part := range []string{"POST /v1/files/upload/", "16777216", "8388608", "http.maxBodyBytes"} {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), part) {
|
||||||
|
t.Fatalf("missing %q: %v", part, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closed != 1 {
|
||||||
|
t.Fatal("cleanup count", closed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultBodyLimitRegistrationPaths(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 4, MaxBodyBytes: 16}))
|
||||||
|
h := func(*httpx.Context) error { t.Error("oversize reached business"); return nil }
|
||||||
|
encoder, _ := response.New(response.Config{})
|
||||||
|
a.Handle("POST", "/handle", h)
|
||||||
|
a.HandleWithResponse("POST", "/response", encoder, h)
|
||||||
|
a.Any("/any", h)
|
||||||
|
g := a.Group("/group")
|
||||||
|
g.POST("/post", h)
|
||||||
|
g.Any("/any", h)
|
||||||
|
g.HandleWithResponse("POST", "/response", encoder, h)
|
||||||
|
for _, path := range []string{"/handle", "/response", "/any", "/group/post", "/group/any", "/group/response"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", path, strings.NewReader("12345"))
|
||||||
|
a.ServeHTTP(w, r)
|
||||||
|
if w.Code != 413 {
|
||||||
|
t.Fatal(path, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
app/i18n_test.go
Normal file
241
app/i18n_test.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func localizedCatalog(namespace string, status int) apierrors.Catalog {
|
||||||
|
return errori18n.Bind(apierrors.Catalog{DefaultStatus: status, Definitions: map[string]apierrors.Definition{
|
||||||
|
"SAME": {Code: int64(9223372036854775807), Message: namespace + ":{value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}},
|
||||||
|
"INTERNAL_ERROR": {Status: 500, Code: int64(500), Message: "internal"},
|
||||||
|
"FIXED": {Status: 401, Code: int64(401), Message: "fixed {literal}"},
|
||||||
|
}}, namespace)
|
||||||
|
}
|
||||||
|
func localizedOptions(t *testing.T, catalogs ...apierrors.Catalog) (*httpi18n.Policy, *errori18n.Localizer) {
|
||||||
|
t.Helper()
|
||||||
|
docs := []i18n.Document{}
|
||||||
|
for _, catalog := range catalogs {
|
||||||
|
source, err := errori18n.Source(catalog, "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
docs = append(docs, source...)
|
||||||
|
for _, d := range source {
|
||||||
|
docs = append(docs, i18n.Document{Version: 1, Namespace: d.Namespace, Locale: "zh-Hans", Messages: map[string]i18n.Message{
|
||||||
|
"SAME": {Value: i18n.Pattern{Text: d.Namespace + "译文:{value}"}}, "FIXED": {Value: i18n.Pattern{Text: "需要认证"}}, "INTERNAL_ERROR": {Value: i18n.Pattern{Text: "系统错误"}},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, docs...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l := errori18n.New(b, nil)
|
||||||
|
for _, c := range catalogs {
|
||||||
|
if err := l.Validate(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, err := httpi18n.New(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p, l
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestI18nRouteScopesEarlyFailuresAndReplay(t *testing.T) {
|
||||||
|
global, local := localizedCatalog("app", 200), localizedCatalog("route", 409)
|
||||||
|
policy, localizer := localizedOptions(t, global, local)
|
||||||
|
format := response.Config{CodeType: "int64", SuccessCode: int64(0), ErrorCode: int64(999), Body: map[string]any{"error": map[string]any{"reason": "${message}", "id": "${code}"}, "ok": "${ok}", "data": "${data}"}, DataMode: "always", Messages: map[string]string{"FIXED": "fixed {literal}"}}
|
||||||
|
app := omni.New(omni.WithErrors(global), omni.WithResponse(format), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
|
||||||
|
defer app.Close()
|
||||||
|
private := errors.New("private-value")
|
||||||
|
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error {
|
||||||
|
switch c.Header("X-Early") {
|
||||||
|
case "auth":
|
||||||
|
return apierrors.ByCode("FIXED")
|
||||||
|
case "panic":
|
||||||
|
panic(private)
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
handler := func(c *httpx.Context) error {
|
||||||
|
if c.Header("X-Params") == "missing" {
|
||||||
|
return apierrors.ByCode("SAME")
|
||||||
|
}
|
||||||
|
return apierrors.ByCode("SAME").WithParams(map[string]any{"value": "{literal}"}).WithCause(private)
|
||||||
|
}
|
||||||
|
app.GET("/global", handler)
|
||||||
|
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
|
||||||
|
})
|
||||||
|
app.HandleWithResponse("GET", "/route", response.RawJSON(response.MustWithErrors(local, response.MustNew(format))), handler, bridge)
|
||||||
|
for _, path := range []string{"/global", "/route"} {
|
||||||
|
for _, locale := range []string{"en", "zh-CN"} {
|
||||||
|
for _, mode := range []string{"", "auth", "panic", "missing"} {
|
||||||
|
r := httptest.NewRequest("GET", path, nil)
|
||||||
|
r.Header.Set("Accept-Language", locale)
|
||||||
|
r.Header.Set("X-Early", mode)
|
||||||
|
r.Header.Set("X-Params", mode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(w, r)
|
||||||
|
var body struct {
|
||||||
|
Error struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
ID json.Number `json:"id"`
|
||||||
|
} `json:"error"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err, w.Body.String())
|
||||||
|
}
|
||||||
|
ns, status := "app", 200
|
||||||
|
if path == "/route" {
|
||||||
|
ns, status = "route", 409
|
||||||
|
}
|
||||||
|
want := ns + ":{literal}"
|
||||||
|
if locale != "en" {
|
||||||
|
want = ns + "译文:{literal}"
|
||||||
|
}
|
||||||
|
if mode == "auth" {
|
||||||
|
status = 401
|
||||||
|
want = "fixed {literal}"
|
||||||
|
if locale != "en" {
|
||||||
|
want = "需要认证"
|
||||||
|
}
|
||||||
|
} else if mode == "panic" || mode == "missing" {
|
||||||
|
status = 500
|
||||||
|
want = "internal"
|
||||||
|
if locale != "en" {
|
||||||
|
want = "系统错误"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.Code != status || body.OK || body.Error.Reason != want || strings.Contains(w.Body.String(), "private-value") {
|
||||||
|
t.Fatal(path, locale, mode, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if mode == "" && body.Error.ID.String() != "9223372036854775807" {
|
||||||
|
t.Fatal("public code changed", body.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, shared := range []bool{false, true} {
|
||||||
|
cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "test", nil }}
|
||||||
|
if shared {
|
||||||
|
cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
|
||||||
|
}
|
||||||
|
idem, err := middleware.NewIdempotency(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := "/localReplay"
|
||||||
|
if shared {
|
||||||
|
path = "/sharedReplay"
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
app.POST(path, func(c *httpx.Context) error { calls++; return handler(c) }, idem.Middleware())
|
||||||
|
var first string
|
||||||
|
for _, locale := range []string{"en", "zh-CN"} {
|
||||||
|
r := httptest.NewRequest("POST", path, strings.NewReader(`{}`))
|
||||||
|
r.Header.Set("Idempotency-Key", "same")
|
||||||
|
r.Header.Set("Accept-Language", locale)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(w, r)
|
||||||
|
if locale == "en" {
|
||||||
|
first = w.Body.String()
|
||||||
|
} else if w.Body.String() != first || w.Header().Get("Idempotency-Replayed") != "true" {
|
||||||
|
t.Fatal("replay changed", w.Body.String(), first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatal("language retriggered business", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestI18nExplicitProtocolsAndConcurrentApps(t *testing.T) {
|
||||||
|
apps := []*omni.App{}
|
||||||
|
for _, ns := range []string{"one", "two"} {
|
||||||
|
c := localizedCatalog(ns, 200)
|
||||||
|
p, l := localizedOptions(t, c)
|
||||||
|
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(l))
|
||||||
|
defer a.Close()
|
||||||
|
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||||
|
a.HandleWithResponse("GET", "/ack", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
|
||||||
|
if !r.IsError() || r.Message != "fixed {literal}" || r.PublicMessage() != "需要认证" {
|
||||||
|
t.Error(r)
|
||||||
|
}
|
||||||
|
return response.Text(200, "ACK"), nil
|
||||||
|
}), func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||||
|
a.GET("/direct", func(c *httpx.Context) error { return c.Text(200, "fixed-protocol") })
|
||||||
|
apps = append(apps, a)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for n := 0; n < 60; n++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
locale, want := "en", "fixed {literal}"
|
||||||
|
if n%2 == 0 {
|
||||||
|
locale, want = "zh-CN", "需要认证"
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("Accept-Language", locale)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
apps[n%len(apps)].ServeHTTP(w, r)
|
||||||
|
if w.Code != 401 || !strings.Contains(w.Body.String(), want) {
|
||||||
|
t.Error(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}(n)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for _, path := range []string{"/ack", "/direct"} {
|
||||||
|
r := httptest.NewRequest("GET", path, nil)
|
||||||
|
r.Header.Set("Accept-Language", "zh-CN")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
apps[0].ServeHTTP(w, r)
|
||||||
|
want := "ACK"
|
||||||
|
if path == "/direct" {
|
||||||
|
want = "fixed-protocol"
|
||||||
|
}
|
||||||
|
if w.Body.String() != want {
|
||||||
|
t.Fatal(w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type panickingLocalizer struct{}
|
||||||
|
|
||||||
|
func (panickingLocalizer) Localize(context.Context, *apierrors.Registry, *apierrors.APIError) (string, bool) {
|
||||||
|
panic("presentation failed")
|
||||||
|
}
|
||||||
|
func TestI18nAdapterFailurePreservesBusiness(t *testing.T) {
|
||||||
|
c := localizedCatalog("test", 200)
|
||||||
|
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithErrorLocalizer(panickingLocalizer{}))
|
||||||
|
defer a.Close()
|
||||||
|
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||||
|
if w.Code != 401 || !strings.Contains(w.Body.String(), "fixed {literal}") {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/requests.go
Normal file
88
app/requests.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admission and completion share a lock: once stopped, no request can enter
|
||||||
|
// between observing zero active handlers and releasing their dependencies.
|
||||||
|
type requestDrain struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
stopped bool
|
||||||
|
active map[*requestWork]struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestWork struct{ cancel context.CancelFunc }
|
||||||
|
|
||||||
|
func (d *requestDrain) begin(ctx context.Context) (context.Context, *requestWork) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.stopped {
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
w := &requestWork{cancel: cancel}
|
||||||
|
if d.active == nil {
|
||||||
|
d.active = make(map[*requestWork]struct{})
|
||||||
|
}
|
||||||
|
d.active[w] = struct{}{}
|
||||||
|
return ctx, w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *requestDrain) end(w *requestWork) {
|
||||||
|
w.cancel()
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
delete(d.active, w)
|
||||||
|
if d.stopped && len(d.active) == 0 {
|
||||||
|
close(d.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *requestDrain) stop() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.stopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.stopped = true
|
||||||
|
if len(d.active) == 0 {
|
||||||
|
close(d.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *requestDrain) cancelAndWait(timeout time.Duration) error {
|
||||||
|
d.stop()
|
||||||
|
d.mu.Lock()
|
||||||
|
for w := range d.active {
|
||||||
|
w.cancel()
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
return nil
|
||||||
|
case <-timer.C:
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return fmt.Errorf("go-omni: HTTP handlers did not finish within cancellation grace %s: %w", timeout,
|
||||||
|
errors.Join(context.DeadlineExceeded, lifecycle.ErrResourcesRetained))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchShutdown stops admission on the early signal without canceling accepted
|
||||||
|
// work. The transport retains its graceful drain budget before Close cancels.
|
||||||
|
func (a *App) watchShutdown(ctx context.Context) func() bool {
|
||||||
|
return context.AfterFunc(ctx, a.requests.stop)
|
||||||
|
}
|
||||||
167
app/requests_test.go
Normal file
167
app/requests_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShutdownWaitsForCancellationCleanup(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
|
a := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(30*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: time.Second}))
|
||||||
|
started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
unblock := func() { once.Do(func() { close(release) }) }
|
||||||
|
defer unblock()
|
||||||
|
var closed atomic.Int32
|
||||||
|
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||||
|
a.GET("/work", func(c *httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-c.Context().Done()
|
||||||
|
close(canceled)
|
||||||
|
<-release
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Error("dependency closed during cancellation cleanup")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- a.ServeContext(ctx, l) }()
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
requestDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(requestDone)
|
||||||
|
res, err := client.Get("https://" + l.Addr().String() + "/work")
|
||||||
|
if err == nil {
|
||||||
|
io.Copy(io.Discard, res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
await(t, started)
|
||||||
|
cancel()
|
||||||
|
await(t, canceled)
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Fatal("dependency closed on cancellation")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatal("returned before handler cleanup", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
// Even an in-memory invocation through Engine must now be rejected.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.Engine().ServeHTTP(w, httptest.NewRequest("GET", "/work", nil))
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("shutdown stuck")
|
||||||
|
}
|
||||||
|
await(t, requestDone)
|
||||||
|
if closed.Load() != 1 {
|
||||||
|
t.Fatal("cleanup count", closed.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalServingEntriesRetainActiveResources(t *testing.T) {
|
||||||
|
for _, entry := range []string{"App", "Engine", "NewServer", "RunWith"} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 30 * time.Millisecond}))
|
||||||
|
started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
var closed atomic.Int32
|
||||||
|
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||||
|
a.GET("/", func(*httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Error("closed while active")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
var h http.Handler = a
|
||||||
|
if entry == "Engine" {
|
||||||
|
h = a.Engine()
|
||||||
|
}
|
||||||
|
if entry == "NewServer" {
|
||||||
|
h = a.NewServer("").Handler
|
||||||
|
}
|
||||||
|
invoke := func(h http.Handler) {
|
||||||
|
defer close(returned)
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||||
|
}
|
||||||
|
defer func() { close(release); await(t, returned) }()
|
||||||
|
var err error
|
||||||
|
if entry == "RunWith" {
|
||||||
|
err = a.RunWith(context.Background(), runnerFunc(func(_ context.Context, h http.Handler, _ string) error { go invoke(h); await(t, started); return nil }))
|
||||||
|
} else {
|
||||||
|
go invoke(h)
|
||||||
|
await(t, started)
|
||||||
|
err = a.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() != 0 {
|
||||||
|
t.Fatal("retention failed", err, closed.Load())
|
||||||
|
}
|
||||||
|
if err = a.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal("repeat close lost retention", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestAdmissionAndCloseRace(t *testing.T) {
|
||||||
|
for attempt := 0; attempt < 20; attempt++ {
|
||||||
|
a := New()
|
||||||
|
var closed atomic.Bool
|
||||||
|
a.OnShutdown(func() error { closed.Store(true); return nil })
|
||||||
|
a.GET("/", func(c *httpx.Context) error {
|
||||||
|
if closed.Load() {
|
||||||
|
t.Error("handler admitted after resource release")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
a.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
if err := a.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||||
routes.Handle(method, path, a.adapt(handler, middleware...))
|
routes.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||||
@@ -28,6 +28,9 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
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))
|
request = request.WithContext(logx.WithLogger(contract.WithRequestPolicy(request.Context(), a.config.Request), a.config.LogInstance))
|
||||||
if len(a.config.TrustedProxies) > 0 {
|
if len(a.config.TrustedProxies) > 0 {
|
||||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
|
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.SetErrorRegistry(provider.ErrorRegistry())
|
||||||
}
|
}
|
||||||
requestContext.SetResponseEncoder(encoder)
|
requestContext.SetResponseEncoder(encoder)
|
||||||
|
requestContext.SetErrorLocalizer(a.config.ErrorLocalizer)
|
||||||
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
|
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
|
||||||
if err := wrapped(requestContext); err != nil {
|
if err := wrapped(requestContext); err != nil {
|
||||||
a.writeError(requestContext, err)
|
a.writeError(requestContext, err)
|
||||||
@@ -58,7 +62,7 @@ func (a *App) HandleWithResponse(method, path string, encoder response.Encoder,
|
|||||||
if encoder == nil {
|
if encoder == nil {
|
||||||
encoder = a.responseEncoder
|
encoder = a.responseEncoder
|
||||||
}
|
}
|
||||||
a.router.Handle(method, path, a.adaptResponse(encoder, handler, middleware...))
|
a.router.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), encoder, handler, middleware...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
@@ -82,18 +86,19 @@ func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Mid
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
a.router.Any(path, a.adapt(handler, middleware...))
|
a.router.Any(path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouterGroup scopes a route prefix and optional middleware.
|
// RouterGroup scopes a route prefix and optional middleware.
|
||||||
type RouterGroup struct {
|
type RouterGroup struct {
|
||||||
|
prefix string
|
||||||
group httpx.RouterGroup
|
group httpx.RouterGroup
|
||||||
app *App
|
app *App
|
||||||
middlewares []httpx.Middleware
|
middlewares []httpx.Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||||
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware}
|
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware, prefix: joinRoutePath("", path)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
||||||
@@ -104,7 +109,7 @@ func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
|||||||
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||||
combined = append(combined, middleware...)
|
combined = append(combined, middleware...)
|
||||||
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined}
|
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined, prefix: joinRoutePath(g.prefix, path)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||||
@@ -124,7 +129,7 @@ func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.E
|
|||||||
}
|
}
|
||||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||||
combined = append(combined, middleware...)
|
combined = append(combined, middleware...)
|
||||||
g.group.Handle(method, path, g.app.adaptResponse(encoder, handler, combined...))
|
g.group.Handle(method, path, g.app.bodyHandler(g.app.defaultBodyLimit(), encoder, handler, combined...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
@@ -150,5 +155,5 @@ func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...h
|
|||||||
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||||
combined = append(combined, middleware...)
|
combined = append(combined, middleware...)
|
||||||
g.group.Any(path, g.app.adapt(handler, combined...))
|
g.group.Any(path, g.app.bodyHandler(g.app.defaultBodyLimit(), nil, handler, combined...))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||||
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServeContext serves a caller-created listener using the configured transport
|
// ServeContext serves a caller-created listener using the configured transport
|
||||||
// and TLS. App takes ownership of the listener, including on startup failure,
|
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||||
// and runs resource cleanup after the server has stopped.
|
// and runs resource cleanup only after admitted handlers have returned.
|
||||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
||||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||||
if listener == nil {
|
if listener == nil {
|
||||||
@@ -30,6 +31,8 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
|||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
return a.Close()
|
return a.Close()
|
||||||
}
|
}
|
||||||
|
stopWatching := a.watchShutdown(ctx)
|
||||||
|
defer stopWatching()
|
||||||
serve, err := a.prepareTransport()
|
serve, err := a.prepareTransport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
@@ -39,6 +42,9 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
||||||
|
if err := a.ValidateHTTP(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := a.config.ValidateTransport(); err != nil {
|
if err := a.config.ValidateTransport(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -57,6 +63,9 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
|||||||
switch a.config.Transport {
|
switch a.config.Transport {
|
||||||
case config.TransportNative:
|
case config.TransportNative:
|
||||||
server := native.New(a)
|
server := native.New(a)
|
||||||
|
// App enforces the service/route policy after routing. Keep only the
|
||||||
|
// supported framing ceiling here, never a hidden smaller transport cap.
|
||||||
|
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||||
server.ShutdownTimeout = shutdownTimeout
|
server.ShutdownTimeout = shutdownTimeout
|
||||||
cfg := a.config.HTTP
|
cfg := a.config.HTTP
|
||||||
if cfg.ReadHeaderTimeout > 0 {
|
if cfg.ReadHeaderTimeout > 0 {
|
||||||
@@ -82,6 +91,7 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
|||||||
return server.Serve, nil
|
return server.Serve, nil
|
||||||
case config.TransportHTTP2:
|
case config.TransportHTTP2:
|
||||||
server := h2transport.New(a)
|
server := h2transport.New(a)
|
||||||
|
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||||
server.ShutdownTimeout = shutdownTimeout
|
server.ShutdownTimeout = shutdownTimeout
|
||||||
if a.config.HTTP.IdleTimeout > 0 {
|
if a.config.HTTP.IdleTimeout > 0 {
|
||||||
server.IdleTimeout = a.config.HTTP.IdleTimeout
|
server.IdleTimeout = a.config.HTTP.IdleTimeout
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfiguredTransportLifecycle(t *testing.T) {
|
func TestConfiguredTransportLifecycle(t *testing.T) {
|
||||||
@@ -269,7 +270,9 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
|||||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
t.Run(transport, func(t *testing.T) {
|
t.Run(transport, func(t *testing.T) {
|
||||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
|
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 40 * time.Millisecond}))
|
||||||
|
var cleaned atomic.Int32
|
||||||
|
application.OnShutdown(func() error { cleaned.Add(1); return nil })
|
||||||
started := make(chan context.Context, 1)
|
started := make(chan context.Context, 1)
|
||||||
release, finished := make(chan struct{}), make(chan struct{})
|
release, finished := make(chan struct{}), make(chan struct{})
|
||||||
application.GET("/blocked", func(ctx *httpx.Context) error {
|
application.GET("/blocked", func(ctx *httpx.Context) error {
|
||||||
@@ -304,11 +307,17 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
|||||||
close(release)
|
close(release)
|
||||||
t.Fatal("handler did not start")
|
t.Fatal("handler did not start")
|
||||||
}
|
}
|
||||||
defer func() { close(release); await(t, finished) }()
|
defer func() {
|
||||||
|
close(release)
|
||||||
|
await(t, finished)
|
||||||
|
if err := application.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||||
|
t.Fatalf("late handler return must not trigger cleanup: %v count=%d", err, cleaned.Load())
|
||||||
|
}
|
||||||
|
}()
|
||||||
cancel()
|
cancel()
|
||||||
select {
|
select {
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||||
t.Fatalf("shutdown: %v", err)
|
t.Fatalf("shutdown: %v", err)
|
||||||
}
|
}
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
|
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。
|
||||||
|
|
||||||
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
|
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
|
||||||
|
|
||||||
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
|
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
|
||||||
@@ -14,7 +16,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
@@ -49,14 +51,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
|||||||
|
|
||||||
## 版本与迁移
|
## 版本与迁移
|
||||||
|
|
||||||
CLI 与框架依赖固定使用 v0.25.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
CLI 与框架依赖固定使用 v0.27.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 在 go-omni 源码仓库中执行
|
# 在 go-omni 源码仓库中执行
|
||||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||||
```
|
```
|
||||||
|
|
||||||
当前模板与依赖以 v0.25.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
当前模板与依赖以 v0.27.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 {
|
for i := range spec.Errors {
|
||||||
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
|
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].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 {
|
if err := validateErrorPolicies(&spec); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import (
|
|||||||
// ErrorDefinition declares a public, stable error. Generated constructors return
|
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||||
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||||
type ErrorDefinition struct {
|
type ErrorDefinition struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BusinessCode any `json:"businessCode,omitempty"`
|
BusinessCode any `json:"businessCode,omitempty"`
|
||||||
Params []apierrors.Parameter `json:"params,omitempty"`
|
Params []apierrors.Parameter `json:"params,omitempty"`
|
||||||
Catalog string `json:"catalog,omitempty"`
|
Translation *apierrors.Translation `json:"translation,omitempty"`
|
||||||
Status int `json:"status"`
|
Catalog string `json:"catalog,omitempty"`
|
||||||
Code string `json:"code"`
|
Status int `json:"status"`
|
||||||
Message string `json:"message"`
|
Code string `json:"code"`
|
||||||
Position string `json:"-"`
|
Message string `json:"message"`
|
||||||
|
Position string `json:"-"`
|
||||||
}
|
}
|
||||||
type AuthDefinition struct {
|
type AuthDefinition struct {
|
||||||
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ func (p *parser) parseErrorCatalog() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
type entry struct {
|
type entry struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Code any `json:"code"`
|
Code any `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
Params []apierrors.Parameter `json:"params"`
|
Params []apierrors.Parameter `json:"params"`
|
||||||
|
Translation *apierrors.Translation `json:"translation"`
|
||||||
}
|
}
|
||||||
var entries map[string]entry
|
var entries map[string]entry
|
||||||
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
||||||
@@ -75,7 +76,7 @@ func (p *parser) parseErrorCatalog() error {
|
|||||||
if e.Status == 0 {
|
if e.Status == 0 {
|
||||||
e.Status = defaultStatus
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
|
|||||||
if names != nil && !selected[e.Name] {
|
if names != nil && !selected[e.Name] {
|
||||||
continue
|
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
|
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.
|
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||||
const minimumVersion = "v0.22.0-preview"
|
const minimumVersion = "v0.22.0-preview"
|
||||||
const currentVersion = "v0.25.0-preview"
|
const currentVersion = "v0.27.0-preview"
|
||||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -194,6 +194,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
}
|
}
|
||||||
seeds["go.mod"] = data
|
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 {
|
if err = module.CheckImportPath(o.Module); err != nil {
|
||||||
return Output{}, err
|
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") }
|
||||||
@@ -30,12 +30,12 @@ func main(){
|
|||||||
func run(ctx context.Context,path string)(runErr error){
|
func run(ctx context.Context,path string)(runErr error){
|
||||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||||
// Unjoined background work may still log. The owning process exits on this
|
// Unjoined HTTP or background work may still log. The owning process exits on this
|
||||||
// error; do not close its borrowed logger before that work has finished.
|
// error; do not close its borrowed logger before that work has finished.
|
||||||
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
||||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
// Finish propagates incomplete HTTP draining: stop background work but retain
|
||||||
// and closes them exactly once, before closing the borrowed logger.
|
// shared dependencies when any handler is still running. Otherwise close once.
|
||||||
defer func(){runErr=deps.Finish(runErr)}()
|
defer func(){runErr=deps.Finish(runErr)}()
|
||||||
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
||||||
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
||||||
|
|||||||
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"example.com/errorCatalog/internal/apierrors"
|
||||||
|
"example.com/errorCatalog/internal/handler"
|
||||||
|
"example.com/errorCatalog/internal/svc"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedTranslationAndParameterCompatibility(t *testing.T) {
|
||||||
|
catalog := apierrors.Catalog()
|
||||||
|
source, err := errori18n.Source(catalog, "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source = append(source, i18n.Document{Version: 1, Locale: "zh-Hans", Namespace: "limits", Messages: map[string]i18n.Message{"exceeded": {Value: i18n.Pattern{Text: "上限:{max}"}}}})
|
||||||
|
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, source...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := httpi18n.New(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lc := logx.Default()
|
||||||
|
lc.Writer = io.Discard
|
||||||
|
logger, err := logx.New(lc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
deps, err := svc.New(context.Background(), logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer deps.Close()
|
||||||
|
a := omni.New(omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(errori18n.New(b, nil)))
|
||||||
|
defer a.Close()
|
||||||
|
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error { return apierrors.LimitExceeded(9223372036854775807) }
|
||||||
|
})
|
||||||
|
if err := handler.RegisterRoutes(a, deps); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/value", nil)
|
||||||
|
r.Header.Set("Accept-Language", "zh-CN")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.ServeHTTP(w, r)
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != 200 || body["detail"] != "上限:9223372036854775807" || body["code"] != float64(400001) || body["ok"] != false {
|
||||||
|
t.Fatal(w.Code, body)
|
||||||
|
}
|
||||||
|
if apierrors.LimitExceeded(5).Message != "Limit: 5" {
|
||||||
|
t.Fatal("non-HTTP constructor changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Opened, Closed, DownloadClosed atomic.Int32
|
var Opened, Closed, DownloadClosed atomic.Int32
|
||||||
|
var SlowReturned atomic.Int32
|
||||||
var Started = make(chan struct{}, 1)
|
var Started = make(chan struct{}, 1)
|
||||||
var Release = make(chan struct{})
|
var Release = make(chan struct{})
|
||||||
var Current atomic.Pointer[ServiceContext]
|
var Current atomic.Pointer[ServiceContext]
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type countedReader struct{ io.Reader }
|
|||||||
|
|
||||||
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
||||||
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
||||||
|
defer svc.SlowReturned.Add(1)
|
||||||
before := svc.Closed.Load()
|
before := svc.Closed.Load()
|
||||||
svc.Started <- struct{}{}
|
svc.Started <- struct{}{}
|
||||||
<-svc.Release
|
<-svc.Release
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"example.com/managed/internal/handler"
|
"example.com/managed/internal/handler"
|
||||||
"example.com/managed/internal/svc"
|
"example.com/managed/internal/svc"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -229,3 +230,80 @@ func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
|
|||||||
t.Fatal("collision leaked resources or started serving", err)
|
t.Fatal("collision leaked resources or started serving", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratedIncompleteHTTPDrainRetainsDependencies(t *testing.T) {
|
||||||
|
for _, transport := range []string{"nethttp", "native"} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
address := l.Addr().String()
|
||||||
|
l.Close()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
logFile := filepath.Join(t.TempDir(), "service.log")
|
||||||
|
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address, "transport": transport, "shutdownTimeout": "30ms", "http": map[string]string{"shutdownCancelTimeout": "30ms"}, "log": map[string]any{"file": logFile, "rotation": map[string]any{"enabled": true}}}})
|
||||||
|
if err = os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc.Started = make(chan struct{}, 1)
|
||||||
|
svc.Release = make(chan struct{})
|
||||||
|
before := svc.Closed.Load()
|
||||||
|
returned := svc.SlowReturned.Load()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- run(ctx, path) }()
|
||||||
|
waitReady(t, "http://"+address, done)
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
requestDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(requestDone)
|
||||||
|
res, err := client.Get("http://" + address + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
io.Copy(io.Discard, res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
await(t, svc.Started)
|
||||||
|
// Capture the channel, because a later test installs its own fixture.
|
||||||
|
release := svc.Release
|
||||||
|
deps := svc.Current.Load()
|
||||||
|
defer func() {
|
||||||
|
close(release)
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for svc.SlowReturned.Load() == returned {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Error("handler did not exit after test release")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
// The test owns the controlled handler and has now joined it.
|
||||||
|
_ = deps.Logger.Close()
|
||||||
|
}()
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err = <-done:
|
||||||
|
if !errors.Is(err, lifecycle.ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("unbounded HTTP shutdown")
|
||||||
|
}
|
||||||
|
if err = deps.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal("deferred Close bypassed retention", err)
|
||||||
|
}
|
||||||
|
if svc.Closed.Load() != before {
|
||||||
|
t.Fatal("generated main closed client during active request")
|
||||||
|
}
|
||||||
|
deps.Logger.Info("retained.http.logger")
|
||||||
|
logs, err := os.ReadFile(logFile)
|
||||||
|
if err != nil || !strings.Contains(string(logs), "retained.http.logger") {
|
||||||
|
t.Fatal("generated main closed logger during active request", err, string(logs))
|
||||||
|
}
|
||||||
|
await(t, requestDone)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
||||||
root := t.TempDir()
|
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
|
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
|
||||||
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
|
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] {
|
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":
|
case "workspace":
|
||||||
if err := runWorkspace(os.Args[2:]); err != nil {
|
if err := runWorkspace(os.Args[2:]); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "go-omni workspace:", err)
|
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, "GoOmni project tool")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Usage:")
|
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 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 [--module <module>] <directory>")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// downloaded module version, which also has a module checksum.
|
||||||
const defaultFrameworkVersion = "v0.25.0-preview"
|
const defaultFrameworkVersion = "v0.27.0-preview"
|
||||||
|
|
||||||
// This target is not a claim that the release already exists. Source users
|
// This target is not a claim that the release already exists. Source users
|
||||||
// select a local framework checkout until the module templates are published.
|
// select a local framework checkout until the module templates are published.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/cors"
|
"git.yuntuo.hk/k2safe/go-omni/cors"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"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/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
@@ -47,6 +48,8 @@ type Config struct {
|
|||||||
Response response.Config `json:"response"`
|
Response response.Config `json:"response"`
|
||||||
ResponseEncoder response.Encoder `json:"-"`
|
ResponseEncoder response.Encoder `json:"-"`
|
||||||
LogInstance *logx.Logger `json:"-"`
|
LogInstance *logx.Logger `json:"-"`
|
||||||
|
LanguagePolicy *httpi18n.Policy `json:"-"`
|
||||||
|
ErrorLocalizer apierrors.Localizer `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -235,7 +238,8 @@ func WithLogConfig(settings logx.Config) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithShutdownTimeout controls how long RunContext waits for active requests.
|
// WithShutdownTimeout controls the graceful HTTP drain budget. After timeout,
|
||||||
|
// HTTP.ShutdownCancelTimeout separately bounds the wait for canceled handlers.
|
||||||
func WithShutdownTimeout(timeout time.Duration) Option {
|
func WithShutdownTimeout(timeout time.Duration) Option {
|
||||||
return func(cfg *Config) {
|
return func(cfg *Config) {
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
@@ -253,6 +257,15 @@ func WithErrors(catalog apierrors.Catalog) Option {
|
|||||||
// WithLogInstance borrows a logger for this App without changing process logging.
|
// WithLogInstance borrows a logger for this App without changing process logging.
|
||||||
func WithLogInstance(logger *logx.Logger) Option { return func(c *Config) { c.LogInstance = logger } }
|
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 {
|
func WithRequestPolicy(policy contract.RequestPolicy) Option {
|
||||||
return func(c *Config) { c.Request = policy }
|
return func(c *Config) { c.Request = policy }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,28 @@ package config
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPConfig controls built-in HTTP servers. Positive values override the
|
// HTTPConfig controls HTTP serving. Network timeouts follow the selected
|
||||||
// selected transport's defaults; zero keeps its existing behavior. Timeouts
|
// transport's semantics, not a handler execution deadline. Body limits and
|
||||||
// follow that transport's semantics, not a handler execution deadline.
|
// shutdown cancellation grace also apply to App used with an external server.
|
||||||
type HTTPConfig struct {
|
type HTTPConfig struct {
|
||||||
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"`
|
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"`
|
||||||
ReadTimeout time.Duration `json:"readTimeout"`
|
ReadTimeout time.Duration `json:"readTimeout"`
|
||||||
WriteTimeout time.Duration `json:"writeTimeout"`
|
WriteTimeout time.Duration `json:"writeTimeout"`
|
||||||
IdleTimeout time.Duration `json:"idleTimeout"`
|
IdleTimeout time.Duration `json:"idleTimeout"`
|
||||||
MaxHeaderBytes int `json:"maxHeaderBytes"`
|
MaxHeaderBytes int `json:"maxHeaderBytes"`
|
||||||
|
// DefaultMaxBodyBytes applies to App routes without an explicit limit.
|
||||||
|
// Zero uses 8 MiB, or the hard limit if smaller. Route limits may override it.
|
||||||
|
DefaultMaxBodyBytes int64 `json:"defaultMaxBodyBytes"`
|
||||||
|
// MaxBodyBytes is an optional service hard limit. Zero adds no hard limit
|
||||||
|
// below the framework's supported maximum of 1 TiB.
|
||||||
|
MaxBodyBytes int64 `json:"maxBodyBytes"`
|
||||||
|
// ShutdownCancelTimeout bounds the wait for handlers after cancellation.
|
||||||
|
// Zero uses 5s. Resources are retained if handlers have not returned.
|
||||||
|
ShutdownCancelTimeout time.Duration `json:"shutdownCancelTimeout"`
|
||||||
// ErrorLog is a Go-only hook for the nethttp server's internal diagnostics.
|
// ErrorLog is a Go-only hook for the nethttp server's internal diagnostics.
|
||||||
ErrorLog *log.Logger `json:"-"`
|
ErrorLog *log.Logger `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -27,6 +38,7 @@ func (c HTTPConfig) Validate() error {
|
|||||||
{"readTimeout", c.ReadTimeout},
|
{"readTimeout", c.ReadTimeout},
|
||||||
{"writeTimeout", c.WriteTimeout},
|
{"writeTimeout", c.WriteTimeout},
|
||||||
{"idleTimeout", c.IdleTimeout},
|
{"idleTimeout", c.IdleTimeout},
|
||||||
|
{"shutdownCancelTimeout", c.ShutdownCancelTimeout},
|
||||||
} {
|
} {
|
||||||
if field.value < 0 {
|
if field.value < 0 {
|
||||||
return &ConfigError{Field: "http." + field.name, Message: "must be nonnegative"}
|
return &ConfigError{Field: "http." + field.name, Message: "must be nonnegative"}
|
||||||
@@ -35,11 +47,22 @@ func (c HTTPConfig) Validate() error {
|
|||||||
if c.MaxHeaderBytes < 0 {
|
if c.MaxHeaderBytes < 0 {
|
||||||
return &ConfigError{Field: "http.maxHeaderBytes", Message: "must be nonnegative"}
|
return &ConfigError{Field: "http.maxHeaderBytes", Message: "must be nonnegative"}
|
||||||
}
|
}
|
||||||
|
for _, field := range []struct {
|
||||||
|
name string
|
||||||
|
value int64
|
||||||
|
}{{"defaultMaxBodyBytes", c.DefaultMaxBodyBytes}, {"maxBodyBytes", c.MaxBodyBytes}} {
|
||||||
|
if field.value < 0 || field.value > httplimits.MaxBodyBytes {
|
||||||
|
return &ConfigError{Field: "http." + field.name, Message: "must be 0..1 TiB (bytes)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.MaxBodyBytes > 0 && c.DefaultMaxBodyBytes > c.MaxBodyBytes {
|
||||||
|
return &ConfigError{Field: "http.defaultMaxBodyBytes", Message: "must not exceed http.maxBodyBytes"}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithHTTPConfig sets the built-in server's HTTP options. RunWith uses a
|
// WithHTTPConfig sets HTTP options. RunWith leaves network configuration to the
|
||||||
// caller-owned Runner and does not apply these options to that runner.
|
// caller-owned Runner, but App body limits and cancellation grace still apply.
|
||||||
func WithHTTPConfig(settings HTTPConfig) Option {
|
func WithHTTPConfig(settings HTTPConfig) Option {
|
||||||
return func(cfg *Config) { cfg.HTTP = settings }
|
return func(cfg *Config) { cfg.HTTP = settings }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,3 +93,40 @@ func TestHTTPInvalidAndUnsupportedSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPBodyAndCancellationSettings(t *testing.T) {
|
||||||
|
for _, cfg := range []config.HTTPConfig{
|
||||||
|
{DefaultMaxBodyBytes: -1}, {MaxBodyBytes: -1}, {DefaultMaxBodyBytes: 1<<40 + 1}, {MaxBodyBytes: 1<<40 + 1},
|
||||||
|
{DefaultMaxBodyBytes: 1024, MaxBodyBytes: 512}, {ShutdownCancelTimeout: -time.Second},
|
||||||
|
} {
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatalf("invalid HTTP config accepted: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Transport = transport
|
||||||
|
cfg.TLS = config.TLSConfig{CertFile: "c", KeyFile: "k"}
|
||||||
|
cfg.HTTP = config.HTTPConfig{DefaultMaxBodyBytes: 1 << 20, MaxBodyBytes: 16 << 20, ShutdownCancelTimeout: time.Second}
|
||||||
|
if err := cfg.ValidateTransport(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Setenv("OMNI_BODY_HARD", "16777216")
|
||||||
|
for _, file := range []struct{ name, body string }{
|
||||||
|
{"config.json", `{"http":{"defaultMaxBodyBytes":1048576,"maxBodyBytes":"${OMNI_BODY_HARD}","shutdownCancelTimeout":"250ms"}}`},
|
||||||
|
{"config.yaml", "http:\n defaultMaxBodyBytes: 1048576\n maxBodyBytes: ${OMNI_BODY_HARD}\n shutdownCancelTimeout: 250ms\n"},
|
||||||
|
} {
|
||||||
|
path := filepath.Join(t.TempDir(), file.name)
|
||||||
|
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.HTTP.DefaultMaxBodyBytes != 1<<20 || cfg.HTTP.MaxBodyBytes != 16<<20 || cfg.HTTP.ShutdownCancelTimeout != 250*time.Millisecond {
|
||||||
|
t.Fatalf("loaded %+v", cfg.HTTP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -380,7 +380,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) {
|
|||||||
object[name] = value
|
object[name] = value
|
||||||
}
|
}
|
||||||
end, err := dec.Token()
|
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 nil, errors.New("unterminated object")
|
||||||
}
|
}
|
||||||
return object, nil
|
return object, nil
|
||||||
@@ -394,7 +397,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) {
|
|||||||
items = append(items, v)
|
items = append(items, v)
|
||||||
}
|
}
|
||||||
end, err := dec.Token()
|
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 nil, errors.New("unterminated array")
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package contract
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/iotest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type byteReader struct{ r io.Reader }
|
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) {
|
func TestStrictRequestPolicy(t *testing.T) {
|
||||||
type request struct {
|
type request struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 统一错误目录
|
# 统一错误目录
|
||||||
|
|
||||||
|
v0.26 新增的通用多语言适配见 [i18n](../i18n/README.md)。目录条目可显式引用 `translation: {namespace, key}`;`errori18n.Bind/Source` 支持从同一份 Go 目录生成源词典。翻译只影响公开展示,不改变业务码、HTTP 状态、失败语义、参数契约或 cause。没有引用或没有启用适配时沿用现有行为;幂等重放保留首次语言。该能力需要 v0.26.0-preview 或更新版本。
|
||||||
|
|
||||||
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
|
从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。
|
||||||
|
|
||||||
## 基本规则
|
## 基本规则
|
||||||
|
|||||||
@@ -9,16 +9,19 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Definition belongs to the project. Code is the public string/integer code;
|
// Definition belongs to the project. Code is the public string/integer code;
|
||||||
// the Catalog map key is the distinct logical identifier used by ByCode.
|
// the Catalog map key is the distinct logical identifier used by ByCode.
|
||||||
// Nil Code retains legacy response code mapping (useful when migrating).
|
// Nil Code retains legacy response code mapping (useful when migrating).
|
||||||
type Definition struct {
|
type Definition struct {
|
||||||
Status int `json:"status,omitempty"`
|
Status int `json:"status,omitempty"`
|
||||||
Code any `json:"code,omitempty"`
|
Code any `json:"code,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Params []Parameter `json:"params,omitempty"`
|
Params []Parameter `json:"params,omitempty"`
|
||||||
|
Translation *Translation `json:"translation,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Catalog struct {
|
type Catalog struct {
|
||||||
@@ -99,6 +102,10 @@ func (c Catalog) Clone() Catalog {
|
|||||||
out := c
|
out := c
|
||||||
out.Definitions = make(map[string]Definition, len(c.Definitions))
|
out.Definitions = make(map[string]Definition, len(c.Definitions))
|
||||||
for key, d := range c.Definitions {
|
for key, d := range c.Definitions {
|
||||||
|
if d.Translation != nil {
|
||||||
|
ref := *d.Translation
|
||||||
|
d.Translation = &ref
|
||||||
|
}
|
||||||
if d.Params != nil {
|
if d.Params != nil {
|
||||||
params := make([]Parameter, len(d.Params))
|
params := make([]Parameter, len(d.Params))
|
||||||
copy(params, d.Params)
|
copy(params, d.Params)
|
||||||
@@ -126,6 +133,13 @@ func NewRegistry(c Catalog) (*Registry, error) {
|
|||||||
}
|
}
|
||||||
messages := map[string][]messagePart{}
|
messages := map[string][]messagePart{}
|
||||||
for key, d := range c.Definitions {
|
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") {
|
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") {
|
||||||
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
|
return nil, fmt.Errorf("errors: invalid logical identifier %q", key)
|
||||||
}
|
}
|
||||||
|
|||||||
46
errors/localization.go
Normal file
46
errors/localization.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package apierrors
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Translation is an explicit reference into an independently owned dictionary.
|
||||||
|
// It does not change the logical error key, status, public code or parameters.
|
||||||
|
type Translation struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localizer is an optional presentation adapter. false preserves the original
|
||||||
|
// public message. Implementations must support concurrent calls and must not
|
||||||
|
// mutate registry or failure, or expose causes/parameter values in diagnostics.
|
||||||
|
type Localizer interface {
|
||||||
|
Localize(context.Context, *Registry, *APIError) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition returns detached metadata for the effective catalog scope.
|
||||||
|
func (r *Registry) Definition(key string) (Definition, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
d, ok := r.catalog.Definitions[key]
|
||||||
|
if d.Params != nil {
|
||||||
|
d.Params = append([]Parameter{}, d.Params...)
|
||||||
|
}
|
||||||
|
if d.Translation != nil {
|
||||||
|
ref := *d.Translation
|
||||||
|
d.Translation = &ref
|
||||||
|
}
|
||||||
|
return d, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameters returns detached public template arguments. This is for explicit
|
||||||
|
// presentation adapters, not automatic serialization into an API response.
|
||||||
|
func (e *APIError) Parameters() map[string]any {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(e.params))
|
||||||
|
for k, v := range e.params {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
34
examples/contracts/i18n.api
Normal file
34
examples/contracts/i18n.api
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
errorCatalog CommonErrors (
|
||||||
|
defaultStatus: 200
|
||||||
|
errors: {
|
||||||
|
"LimitExceeded": {
|
||||||
|
"key": "LIMIT_EXCEEDED", "code": 400001,
|
||||||
|
"message": "Limit: {max}",
|
||||||
|
"params": [{"name":"max","type":"int64","example":100}],
|
||||||
|
"translation": {"namespace":"limits","key":"exceeded"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response CommonResponse (
|
||||||
|
codeType: int32
|
||||||
|
successCode: 0
|
||||||
|
errorCode: 9999
|
||||||
|
body: {"code":"${code}", "detail":"${message}", "ok":"${ok}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
type ValueResponse {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
@server (
|
||||||
|
group: limits
|
||||||
|
response: CommonResponse
|
||||||
|
errors: LimitExceeded
|
||||||
|
)
|
||||||
|
service localized-api {
|
||||||
|
@handler value
|
||||||
|
get /value returns (ValueResponse)
|
||||||
|
}
|
||||||
15
examples/httpSafety/README.md
Normal file
15
examples/httpSafety/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# HTTP 限额与停机
|
||||||
|
|
||||||
|
从仓库根目录运行 `go run ./examples/httpSafety`,可用 `-addr 127.0.0.1:18080` 指定端口。仅使用本机 HTTP 和模拟收尾,不连接数据库或其他服务。
|
||||||
|
|
||||||
|
上传验证:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dd if=/dev/zero bs=1048576 count=9 2>/dev/null | curl --data-binary @- http://127.0.0.1:8080/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
普通路由默认 1 MiB,上传路由允许 16 MiB,服务硬上限 20 MiB;以上请求返回已读取的 9437184 字节。将上传路由改为 21 MiB 后,启动会报告该路由与服务硬上限冲突。实际文件上传须额外预留 multipart 字段/边界开销,并配置文件大小限制。
|
||||||
|
|
||||||
|
停机验证:另一个终端执行 `curl http://127.0.0.1:8080/work`,随后在服务终端按 Ctrl-C。先等待 1s 排空,再取消请求,处理器模拟 100ms 收尾;日志中先出现 `request cleanup completed`,再出现资源关闭记录。因为用尽排空预算,本次退出仍返回超时错误。
|
||||||
|
|
||||||
|
若处理器不在取消收尾窗口内退出,App 和 `deps.Finish(runErr)` 返回 `lifecycle.ErrResourcesRetained` 并跳过资源关闭。真实项目拥有的日志也必须在该错误下保留,生成入口已处理。进程应非零退出;库不自行退出进程,也不强杀 goroutine。
|
||||||
62
examples/httpSafety/main.go
Normal file
62
examples/httpSafety/main.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// This example uses only local HTTP and simulated work; no external services.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
address := flag.String("addr", "127.0.0.1:8080", "local listen address")
|
||||||
|
flag.Parse()
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := run(ctx, *address); err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, address string) (runErr error) {
|
||||||
|
deps := lifecycle.NewRuntime(ctx)
|
||||||
|
deps.OnClose(func() error { log.Print("shared resource closed after requests completed"); return nil })
|
||||||
|
defer func() { runErr = deps.Finish(runErr) }()
|
||||||
|
app := omni.New(omni.WithHTTPConfig(omni.HTTPConfig{
|
||||||
|
DefaultMaxBodyBytes: 1 << 20,
|
||||||
|
MaxBodyBytes: 20 << 20,
|
||||||
|
ShutdownCancelTimeout: time.Second,
|
||||||
|
}), omni.WithShutdownTimeout(time.Second))
|
||||||
|
defer func() { runErr = errors.Join(runErr, app.Close()) }()
|
||||||
|
app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, func(c *omni.Context) error {
|
||||||
|
n, err := io.Copy(io.Discard, c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.OK(map[string]int64{"bytes": n})
|
||||||
|
})
|
||||||
|
app.GET("/work", func(c *omni.Context) error {
|
||||||
|
log.Print("request accepted")
|
||||||
|
select {
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
return c.OK("finished")
|
||||||
|
case <-c.Context().Done():
|
||||||
|
// Simulate bounded rollback/cleanup after cancellation. Dependencies
|
||||||
|
// remain available until this handler and its defers have returned.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
log.Print("request cleanup completed")
|
||||||
|
return c.Context().Err()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
log.Printf("listening on %s; POST /upload or GET /work, then Ctrl-C", address)
|
||||||
|
return app.RunContext(deps.Context(), address)
|
||||||
|
}
|
||||||
28
examples/i18n/README.md
Normal file
28
examples/i18n/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 独立国际化、HTTP 和前端示例
|
||||||
|
|
||||||
|
不依赖数据库、其他项目或外部服务。默认作为 CLI 运行;同一 Bundle 也接入 HTTP 错误和前端导出。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/i18n
|
||||||
|
go run ./examples/i18n -serve 127.0.0.1:8098
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://127.0.0.1:8098/`,输入草稿并切换语言。只更新标题、标签和按钮文字;输入内容、提交次数和表单节点保留。浏览器模拟 `/locales/zh-Hans.json` 加载失败时保留当前语言;快速连续切换不会被旧加载覆盖。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -i -H 'Accept-Language: zh-CN' http://127.0.0.1:8098/api/limit
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 HTTP 409,业务码 400001,中文公开提示;改成 `en` 时只改变展示文案。静态页面和语言包使用直接输出,不受公共响应格式包装。
|
||||||
|
|
||||||
|
项目管理普通 UI 词典;错误源文案只在 Go Catalog 中维护,`errori18n.Source` 在装配时提供源词典,JSON 只维护中文译文。所有语言包显式嵌入,不受进程工作目录或环境变量影响。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/i18n -export-locale zh-Hans
|
||||||
|
go run ./examples/i18n -catalog > /tmp/omni-i18n-errors.json
|
||||||
|
go run ./cmd/go-omni i18n extract --catalog /tmp/omni-i18n-errors.json \
|
||||||
|
--locale en --namespace errors
|
||||||
|
go test ./examples/i18n
|
||||||
|
```
|
||||||
|
|
||||||
|
前端 Pack 只导出 `ui` 命名空间,不把错误业务码、HTTP 状态、内部 cause 或未选择的文案混进去。更多检查与类型导出命令见 [通用说明](../../i18n/README.md)。
|
||||||
6
examples/i18n/config.json
Normal file
6
examples/i18n/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sourceLocale": "en",
|
||||||
|
"defaultLocale": "en",
|
||||||
|
"locales": ["en", "zh-Hans"],
|
||||||
|
"fallbacks": {"zh-Hans": ["en"]}
|
||||||
|
}
|
||||||
46
examples/i18n/index.html
Normal file
46
examples/i18n/index.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>GoOmni language example</title>
|
||||||
|
<style>
|
||||||
|
body{font:17px system-ui,sans-serif;max-width:620px;margin:60px auto;padding:0 24px;color:#172c36;background:#f6f8fa}h1{font-size:30px}label{display:block;margin:24px 0 8px}input,select,button{font:inherit;padding:10px;border:1px solid #9babb3;border-radius:6px}input{box-sizing:border-box;width:100%}button{margin-top:16px;background:#17627a;color:white}p{min-height:24px}#status{color:#a12626}
|
||||||
|
</style>
|
||||||
|
<main>
|
||||||
|
<select id="language" aria-label="Language"><option value="en">English</option><option value="zh-Hans">简体中文</option></select>
|
||||||
|
<h1 id="title">Language example</h1>
|
||||||
|
<form id="form">
|
||||||
|
<label id="draftLabel" for="draft">Your draft</label>
|
||||||
|
<input id="draft" name="draft" autocomplete="off">
|
||||||
|
<button id="submit" type="submit">Submit demo</button>
|
||||||
|
</form>
|
||||||
|
<p id="count" aria-live="polite"></p>
|
||||||
|
<p id="status" role="status"></p>
|
||||||
|
</main>
|
||||||
|
<script type="module">
|
||||||
|
import {createLanguageController, fetchPack} from '/runtime.mjs';
|
||||||
|
const select=document.querySelector('#language'), status=document.querySelector('#status');
|
||||||
|
const controller=createLanguageController(await fetchPack('/locales/en.json'), locale=>fetchPack(`/locales/${encodeURIComponent(locale)}.json`));
|
||||||
|
let count=0;
|
||||||
|
function paint(){
|
||||||
|
const {t,locale}=controller.current;
|
||||||
|
document.documentElement.lang=locale;
|
||||||
|
document.querySelector('#title').textContent=t('ui','title');
|
||||||
|
document.querySelector('#draftLabel').textContent=t('ui','draft');
|
||||||
|
document.querySelector('#submit').textContent=t('ui','submit');
|
||||||
|
document.querySelector('#count').textContent=t('ui','submitted',{count});
|
||||||
|
select.value=locale;
|
||||||
|
}
|
||||||
|
select.addEventListener('change',async()=>{
|
||||||
|
status.textContent='';
|
||||||
|
try{if(await controller.setLanguage(select.value))paint();}
|
||||||
|
catch{select.value=controller.current.locale;status.textContent='Language could not be loaded. / 语言加载失败。';}
|
||||||
|
});
|
||||||
|
document.querySelector('#form').addEventListener('submit',async event=>{
|
||||||
|
event.preventDefault();
|
||||||
|
const response=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','Accept-Language':controller.current.locale},body:JSON.stringify({draft:document.querySelector('#draft').value})});
|
||||||
|
if(response.ok){count++;paint();}
|
||||||
|
});
|
||||||
|
paint();
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
8
examples/i18n/locales/errors.zh-Hans.json
Normal file
8
examples/i18n/locales/errors.zh-Hans.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"namespace": "errors",
|
||||||
|
"locale": "zh-Hans",
|
||||||
|
"messages": {
|
||||||
|
"LIMIT_EXCEEDED": {"value": {"text": "数量不能超过 {max}"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
examples/i18n/locales/ui.en.json
Normal file
15
examples/i18n/locales/ui.en.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"namespace": "ui",
|
||||||
|
"locale": "en",
|
||||||
|
"messages": {
|
||||||
|
"title": {"value": {"text": "Language example"}},
|
||||||
|
"draft": {"value": {"text": "Your draft"}},
|
||||||
|
"submit": {"value": {"text": "Submit demo"}},
|
||||||
|
"greeting": {"params": {"name": "string"}, "value": {"text": "Hello, {name}"}},
|
||||||
|
"submitted": {"params": {"count": "integer"}, "value": {"plural": "count", "cases": {
|
||||||
|
"one": {"text": "Submitted {count} time"},
|
||||||
|
"other": {"text": "Submitted {count} times"}
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
examples/i18n/locales/ui.zh-Hans.json
Normal file
12
examples/i18n/locales/ui.zh-Hans.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"namespace": "ui",
|
||||||
|
"locale": "zh-Hans",
|
||||||
|
"messages": {
|
||||||
|
"title": {"value": {"text": "语言切换示例"}},
|
||||||
|
"draft": {"value": {"text": "你的草稿"}},
|
||||||
|
"submit": {"value": {"text": "提交演示"}},
|
||||||
|
"greeting": {"value": {"text": "你好,{name}"}},
|
||||||
|
"submitted": {"value": {"plural": "count", "cases": {"other": {"text": "已提交 {count} 次"}}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
examples/i18n/main.go
Normal file
135
examples/i18n/main.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
browseri18n "git.yuntuo.hk/k2safe/go-omni/i18n/browser"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed config.json locales/*.json index.html
|
||||||
|
var resources embed.FS
|
||||||
|
|
||||||
|
func catalog() apierrors.Catalog {
|
||||||
|
return errori18n.Bind(apierrors.Catalog{Definitions: map[string]apierrors.Definition{
|
||||||
|
"LIMIT_EXCEEDED": {Status: 409, Code: 400001, Message: "Count must not exceed {max}", Params: []apierrors.Parameter{{Name: "max", Type: "int64"}}},
|
||||||
|
}}, "errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
func bundle() (*i18n.Bundle, error) {
|
||||||
|
raw, err := resources.ReadFile("config.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg i18n.Config
|
||||||
|
if err := i18n.DecodeJSON(raw, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
docs, err := i18n.LoadFS(resources, "locales/ui.en.json", "locales/ui.zh-Hans.json", "locales/errors.zh-Hans.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
source, err := errori18n.Source(catalog(), cfg.SourceLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
docs = append(docs, source...)
|
||||||
|
return i18n.New(cfg, docs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(b *i18n.Bundle) (*omni.App, error) {
|
||||||
|
policy, err := httpi18n.New(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
localizer := errori18n.New(b, nil)
|
||||||
|
if err := localizer.Validate(catalog()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := omni.New(omni.WithErrors(catalog()), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999}), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
|
||||||
|
a.GET("/", func(c *httpx.Context) error {
|
||||||
|
data, err := resources.ReadFile("index.html")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Bytes(200, "text/html; charset=utf-8", data)
|
||||||
|
})
|
||||||
|
a.GET("/runtime.mjs", func(c *httpx.Context) error {
|
||||||
|
data, err := fs.ReadFile(browseri18n.Files(), "runtime.mjs")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Bytes(200, "text/javascript; charset=utf-8", data)
|
||||||
|
})
|
||||||
|
a.GET("/locales/:file", func(c *httpx.Context) error {
|
||||||
|
var locale string
|
||||||
|
switch c.Param("file") {
|
||||||
|
case "en.json":
|
||||||
|
locale = "en"
|
||||||
|
case "zh-Hans.json":
|
||||||
|
locale = "zh-Hans"
|
||||||
|
default:
|
||||||
|
return apierrors.NotFound("NOT_FOUND", "language pack not found")
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := b.Export(&out, locale, "ui"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Bytes(200, "application/json; charset=utf-8", out.Bytes())
|
||||||
|
})
|
||||||
|
a.GET("/api/limit", func(*httpx.Context) error {
|
||||||
|
return apierrors.ByCode("LIMIT_EXCEEDED").WithParams(map[string]any{"max": int64(100)})
|
||||||
|
})
|
||||||
|
a.POST("/api/action", func(c *httpx.Context) error { return c.OK(map[string]bool{"accepted": true}) })
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
serve := flag.String("serve", "", "optional local HTTP listen address")
|
||||||
|
locale := flag.String("export-locale", "", "export a UI language pack to stdout")
|
||||||
|
exportCatalog := flag.Bool("catalog", false, "export the Go error catalog to stdout")
|
||||||
|
flag.Parse()
|
||||||
|
if err := run(*serve, *locale, *exportCatalog); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func run(serve, locale string, exportCatalog bool) error {
|
||||||
|
if exportCatalog {
|
||||||
|
return apierrors.ExportCatalog(os.Stdout, catalog())
|
||||||
|
}
|
||||||
|
b, err := bundle()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if locale != "" {
|
||||||
|
return b.Export(os.Stdout, locale, "ui")
|
||||||
|
}
|
||||||
|
if serve != "" {
|
||||||
|
a, err := application(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer a.Close()
|
||||||
|
return a.Run(serve)
|
||||||
|
}
|
||||||
|
for _, l := range b.Config().Locales {
|
||||||
|
result, err := b.Render(l, i18n.Ref{Namespace: "ui", Key: "greeting"}, map[string]any{"name": "Alex"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(result.Locale + ": " + result.Text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
34
examples/i18n/main_test.go
Normal file
34
examples/i18n/main_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIndependentExampleAndHTTP(t *testing.T) {
|
||||||
|
b, err := bundle()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if missing := b.Check(); len(missing) != 0 {
|
||||||
|
t.Fatal(missing)
|
||||||
|
}
|
||||||
|
a, err := application(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer a.Close()
|
||||||
|
for _, test := range []struct {
|
||||||
|
path, part string
|
||||||
|
status int
|
||||||
|
}{{"/api/limit", "数量不能超过 100", 409}, {"/locales/zh-Hans.json", `"namespace": "ui"`, 200}, {"/runtime.mjs", "createLanguageController", 200}, {"/", "draft", 200}} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", test.path, nil)
|
||||||
|
r.Header.Set("Accept-Language", "zh-CN")
|
||||||
|
a.ServeHTTP(w, r)
|
||||||
|
if w.Code != test.status || !strings.Contains(w.Body.String(), test.part) {
|
||||||
|
t.Fatal(test.path, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
|
"internal/svc/runtimeGen.go": "29f6afb6614881935099c1845ecb3f7902ee773caa077940f566cb8dfdbcdbb1",
|
||||||
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
"internal/types/dtoManagedd28645a2af1cGen.go": "7586e86fa5d67051283178cc578c890f6c02fd2d5c383170a71a39c2e77ce344",
|
||||||
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
"internal/types/typesGen.go": "372d67f23a313b3e77d2a3b7a236315db9721bc63350bf1f71db1293e57570d1",
|
||||||
"main.go": "10cb6a84b2eb1b68dc10c2dca949c9dd7c68a9f78a8e6d8f56ce9336d4b2d125"
|
"main.go": "e1cdd80f8ee090f1391cdfa799762c99b10d4e09715e9d4faefb2cb0291f1c05"
|
||||||
},
|
},
|
||||||
"contract": {
|
"contract": {
|
||||||
"routes": {
|
"routes": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module example.com/managed
|
|||||||
|
|
||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
require git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
|
require git.yuntuo.hk/k2safe/go-omni v0.27.0-preview
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func run(ctx context.Context, path string) (runErr error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Unjoined background work may still log. The owning process exits on this
|
// Unjoined HTTP or background work may still log. The owning process exits on this
|
||||||
// error; do not close its borrowed logger before that work has finished.
|
// error; do not close its borrowed logger before that work has finished.
|
||||||
defer func() {
|
defer func() {
|
||||||
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
|
if !errors.Is(runErr, lifecycle.ErrResourcesRetained) {
|
||||||
@@ -52,8 +52,8 @@ func run(ctx context.Context, path string) (runErr error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
// Finish propagates incomplete HTTP draining: stop background work but retain
|
||||||
// and closes them exactly once, before closing the borrowed logger.
|
// shared dependencies when any handler is still running. Otherwise close once.
|
||||||
defer func() { runErr = deps.Finish(runErr) }()
|
defer func() { runErr = deps.Finish(runErr) }()
|
||||||
app, err := handler.NewApp(deps)
|
app, err := handler.NewApp(deps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module example.com/userService
|
|||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
|
git.yuntuo.hk/k2safe/go-omni v0.27.0-preview
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
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
|
go.opentelemetry.io/otel/trace v1.31.0
|
||||||
golang.org/x/mod v0.21.0
|
golang.org/x/mod v0.21.0
|
||||||
golang.org/x/net v0.33.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/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
@@ -48,6 +49,5 @@ require (
|
|||||||
go.uber.org/atomic v1.7.0 // indirect
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
golang.org/x/sys v0.28.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
|
golang.org/x/time v0.5.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/validate"
|
"git.yuntuo.hk/k2safe/go-omni/validate"
|
||||||
@@ -88,15 +89,16 @@ func RoutePattern(ctx context.Context) string {
|
|||||||
// only standard-library HTTP types, so it remains usable when the network
|
// only standard-library HTTP types, so it remains usable when the network
|
||||||
// implementation is replaced.
|
// implementation is replaced.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
request *http.Request
|
request *http.Request
|
||||||
writer http.ResponseWriter
|
writer http.ResponseWriter
|
||||||
params map[string]string
|
params map[string]string
|
||||||
values map[string]any
|
values map[string]any
|
||||||
responded bool
|
responded bool
|
||||||
status int
|
status int
|
||||||
encoder response.Encoder
|
encoder response.Encoder
|
||||||
errorRegistry *apierrors.Registry
|
errorRegistry *apierrors.Registry
|
||||||
failure *apierrors.APIError
|
errorLocalizer apierrors.Localizer
|
||||||
|
failure *apierrors.APIError
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContext creates a transport-neutral GoOmni context.
|
// NewContext creates a transport-neutral GoOmni context.
|
||||||
@@ -136,6 +138,7 @@ func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Contex
|
|||||||
clone := NewContext(c.request.Clone(ctx), writer, c.params)
|
clone := NewContext(c.request.Clone(ctx), writer, c.params)
|
||||||
clone.encoder = c.encoder
|
clone.encoder = c.encoder
|
||||||
clone.errorRegistry = c.errorRegistry
|
clone.errorRegistry = c.errorRegistry
|
||||||
|
clone.errorLocalizer = c.errorLocalizer
|
||||||
for key, value := range c.values {
|
for key, value := range c.values {
|
||||||
clone.values[key] = value
|
clone.values[key] = value
|
||||||
}
|
}
|
||||||
@@ -282,7 +285,12 @@ func (c *Context) WriteError(err error) {
|
|||||||
if errors.As(err, &bodyErr) {
|
if errors.As(err, &bodyErr) {
|
||||||
err = bodyTooLarge()
|
err = bodyTooLarge()
|
||||||
}
|
}
|
||||||
|
var original *apierrors.APIError
|
||||||
|
tooLarge := errors.As(err, &original) && original.Status == http.StatusRequestEntityTooLarge
|
||||||
apiErr := c.ResolveError(err)
|
apiErr := c.ResolveError(err)
|
||||||
|
if tooLarge || apiErr.Status == http.StatusRequestEntityTooLarge {
|
||||||
|
httplimits.RejectBody(c.Raw())
|
||||||
|
}
|
||||||
c.failure = apiErr
|
c.failure = apiErr
|
||||||
if writeErr := c.writeResult(response.Result{
|
if writeErr := c.writeResult(response.Result{
|
||||||
BusinessCode: apiErr.BusinessCode,
|
BusinessCode: apiErr.BusinessCode,
|
||||||
@@ -301,16 +309,20 @@ func (c *Context) WriteError(err error) {
|
|||||||
// SetErrorRegistry sets an application fallback. Route encoders can supply their
|
// SetErrorRegistry sets an application fallback. Route encoders can supply their
|
||||||
// own nonempty registry; an encoder without a registry inherits this fallback.
|
// own nonempty registry; an encoder without a registry inherits this fallback.
|
||||||
// Clones retain both policies without any process-global lookup.
|
// Clones retain both policies without any process-global lookup.
|
||||||
func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r }
|
func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r }
|
||||||
func (c *Context) ResolveError(err error) *apierrors.APIError {
|
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
|
registry := c.errorRegistry
|
||||||
if provider, ok := c.encoder.(response.ErrorRegistryProvider); ok {
|
if provider, ok := c.encoder.(response.ErrorRegistryProvider); ok {
|
||||||
if scoped := provider.ErrorRegistry(); scoped != nil {
|
if scoped := provider.ErrorRegistry(); scoped != nil {
|
||||||
registry = scoped
|
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.
|
// RecordError preserves failure semantics when a middleware commits buffered bytes.
|
||||||
func (c *Context) RecordError(err error) { c.failure = c.ResolveError(err) }
|
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 {
|
if encoder == nil {
|
||||||
encoder = response.Default()
|
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)
|
payload, err := encoder.Encode(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apierrors.Internal(err)
|
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)
|
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
|
// RespondStatus uses the current response format and error catalog, but keeps
|
||||||
// the explicit HTTP status even if a catalog/custom encoder selects another.
|
// the explicit HTTP status even if a catalog/custom encoder selects another.
|
||||||
// This is useful for protocol endpoints such as health probes. Ordinary
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
123
i18n/export.go
Normal file
123
i18n/export.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pack is a self-contained browser artifact for one selected language. Its
|
||||||
|
// documents include the entire configured fallback chain and parameter schemas.
|
||||||
|
type Pack struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
Config Config `json:"config"`
|
||||||
|
Documents []Document `json:"documents"`
|
||||||
|
RuleVersion string `json:"ruleVersion"`
|
||||||
|
PluralRules map[string]map[string]string `json:"pluralRules"`
|
||||||
|
License string `json:"license"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bundle) namespaces(names []string) error {
|
||||||
|
if b == nil {
|
||||||
|
return fmt.Errorf("i18n: bundle required")
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return fmt.Errorf("i18n: export requires explicit namespaces")
|
||||||
|
}
|
||||||
|
known := map[string]bool{}
|
||||||
|
for ref := range b.entries {
|
||||||
|
known[ref.Namespace] = true
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, ns := range names {
|
||||||
|
if !known[ns] || seen[ns] {
|
||||||
|
return fmt.Errorf("i18n: export namespace is unknown or duplicated: %s", ns)
|
||||||
|
}
|
||||||
|
seen[ns] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bundle) Export(w io.Writer, locale string, namespaces ...string) error {
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("i18n: writer required")
|
||||||
|
}
|
||||||
|
if err := b.namespaces(namespaces); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
l, err := canonical(locale)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := b.tags[l]; !ok {
|
||||||
|
return fmt.Errorf("i18n: export locale must be enabled")
|
||||||
|
}
|
||||||
|
docs := []Document{}
|
||||||
|
for _, candidate := range b.chain(l) {
|
||||||
|
docs = append(docs, b.Documents(candidate, namespaces...)...)
|
||||||
|
}
|
||||||
|
// Source is always needed by clients even if absent from an explicit chain.
|
||||||
|
rules := map[string]map[string]string{}
|
||||||
|
for _, locale := range b.chain(l) {
|
||||||
|
rules[locale] = rulesFor(language.MustParse(locale)).text
|
||||||
|
}
|
||||||
|
pack := Pack{Version: Version, Locale: l, Config: b.Config(), Documents: docs, RuleVersion: PluralRulesVersion, PluralRules: rules, License: unicodeLicense}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buffer)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(pack); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if buffer.Len() > 4<<20 {
|
||||||
|
return fmt.Errorf("i18n: exported pack exceeds 4 MiB; split namespaces")
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, &buffer)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript emits nested namespace/key types, so keys containing punctuation
|
||||||
|
// never collide through concatenation or Go-specific identifier conversion.
|
||||||
|
func (b *Bundle) TypeScript(w io.Writer, namespaces ...string) error {
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("i18n: writer required")
|
||||||
|
}
|
||||||
|
if err := b.namespaces(namespaces); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
out.WriteString("// Generated by go-omni i18n types. Do not edit.\nexport interface MessageParams {\n")
|
||||||
|
for _, doc := range b.Documents(b.config.SourceLocale, namespaces...) {
|
||||||
|
fmt.Fprintf(&out, " %s: {\n", strconv.Quote(doc.Namespace))
|
||||||
|
for _, key := range sortedKeys(doc.Messages) {
|
||||||
|
m := doc.Messages[key]
|
||||||
|
if len(m.Params) == 0 {
|
||||||
|
fmt.Fprintf(&out, " %s: Record<string, never>;\n", strconv.Quote(key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&out, " %s: {", strconv.Quote(key))
|
||||||
|
for _, name := range sortedKeys(m.Params) {
|
||||||
|
typ := "string"
|
||||||
|
switch m.Params[name] {
|
||||||
|
case Boolean:
|
||||||
|
typ = "boolean"
|
||||||
|
case Integer:
|
||||||
|
typ = "number | bigint | `${bigint}`"
|
||||||
|
case Number:
|
||||||
|
typ = "number"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&out, " %s: %s;", strconv.Quote(name), typ)
|
||||||
|
}
|
||||||
|
out.WriteString(" };\n")
|
||||||
|
}
|
||||||
|
out.WriteString(" };\n")
|
||||||
|
}
|
||||||
|
out.WriteString("}\nexport type Namespace = keyof MessageParams;\nexport type Translate = <N extends Namespace, K extends Extract<keyof MessageParams[N], string>>(namespace: N, key: K, params: Extract<MessageParams[N][K], Record<string, unknown>>) => string;\n")
|
||||||
|
_, err := io.WriteString(w, out.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
131
i18n/httpi18n/http.go
Normal file
131
i18n/httpi18n/http.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Package httpi18n negotiates an i18n language for standard HTTP requests.
|
||||||
|
package httpi18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolver supplies ordered language tags, e.g. from an authenticated preference.
|
||||||
|
// It must be safe for concurrent requests. It does not receive a mutable Bundle.
|
||||||
|
// A panic is treated as no preference, preserving request execution.
|
||||||
|
type Resolver func(*http.Request) []string
|
||||||
|
type Option func(*Policy) error
|
||||||
|
type Policy struct {
|
||||||
|
bundle *i18n.Bundle
|
||||||
|
resolver Resolver
|
||||||
|
vary []string
|
||||||
|
private bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithResolver overrides the default Accept-Language resolver. Cache variation
|
||||||
|
// is explicit. With no varying headers, responses are marked private.
|
||||||
|
func WithResolver(resolver Resolver, varyHeaders ...string) Option {
|
||||||
|
return func(p *Policy) error {
|
||||||
|
if resolver == nil {
|
||||||
|
return fmt.Errorf("httpi18n: nil resolver")
|
||||||
|
}
|
||||||
|
p.resolver = resolver
|
||||||
|
p.vary = nil
|
||||||
|
p.private = len(varyHeaders) == 0
|
||||||
|
for _, h := range varyHeaders {
|
||||||
|
if !headerName(h) {
|
||||||
|
return fmt.Errorf("httpi18n: invalid Vary header")
|
||||||
|
}
|
||||||
|
p.vary = append(p.vary, http.CanonicalHeaderKey(h))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func headerName(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if c > 127 || !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", c)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(bundle *i18n.Bundle, options ...Option) (*Policy, error) {
|
||||||
|
if bundle == nil {
|
||||||
|
return nil, fmt.Errorf("httpi18n: bundle required")
|
||||||
|
}
|
||||||
|
p := &Policy{bundle: bundle, vary: []string{"Accept-Language"}, resolver: func(r *http.Request) []string {
|
||||||
|
header := r.Header.Get("Accept-Language")
|
||||||
|
if len(header) > 8192 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tags, _, err := language.ParseAcceptLanguage(header)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
for _, tag := range tags {
|
||||||
|
if len(out) == 64 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, tag.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}}
|
||||||
|
for _, o := range options {
|
||||||
|
if o == nil {
|
||||||
|
return nil, fmt.Errorf("httpi18n: nil option")
|
||||||
|
}
|
||||||
|
if err := o(p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply installs the negotiated language before application middleware. An
|
||||||
|
// explicit context preference takes priority and makes the response private.
|
||||||
|
// It does not claim Content-Language: individual messages may use fallbacks.
|
||||||
|
func (p *Policy) Apply(w http.ResponseWriter, r *http.Request) *http.Request {
|
||||||
|
if p == nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
preferences := []string{}
|
||||||
|
explicit := i18n.Locale(r.Context())
|
||||||
|
if explicit != "" {
|
||||||
|
preferences = append(preferences, explicit)
|
||||||
|
}
|
||||||
|
preferences = append(preferences, p.resolve(r)...)
|
||||||
|
for _, h := range p.vary {
|
||||||
|
addVary(w.Header(), h)
|
||||||
|
}
|
||||||
|
if p.private || explicit != "" {
|
||||||
|
w.Header().Add("Cache-Control", "private")
|
||||||
|
}
|
||||||
|
return r.WithContext(i18n.WithLocale(r.Context(), p.bundle.Match(preferences...)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Policy) resolve(r *http.Request) (preferences []string) {
|
||||||
|
defer func() {
|
||||||
|
if recover() != nil {
|
||||||
|
preferences = nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return p.resolver(r)
|
||||||
|
}
|
||||||
|
func addVary(h http.Header, value string) {
|
||||||
|
for _, line := range h.Values("Vary") {
|
||||||
|
for _, v := range strings.Split(line, ",") {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(v), value) || strings.TrimSpace(v) == "*" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.Add("Vary", value)
|
||||||
|
}
|
||||||
|
func (p *Policy) Wrap(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, p.Apply(w, r)) })
|
||||||
|
}
|
||||||
59
i18n/httpi18n/http_test.go
Normal file
59
i18n/httpi18n/http_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package httpi18n_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNegotiationAndExplicitResolvers(t *testing.T) {
|
||||||
|
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "fr", "zh-Hans", "zh-Hant"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := httpi18n.New(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct{ header, explicit, want string }{{"fr;q=0.8,en;q=0.1", "", "fr"}, {"fr;q=0,en;q=1", "", "en"}, {"zh-TW", "", "zh-Hant"}, {"zh-CN", "", "zh-Hans"}, {"invalid;garbage", "", "en"}, {strings.Repeat("x", 8193), "", "en"}, {"fr", "en", "en"}} {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("Accept-Language", test.header)
|
||||||
|
if test.explicit != "" {
|
||||||
|
r = r.WithContext(i18n.WithLocale(r.Context(), test.explicit))
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
p.Wrap(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := i18n.Locale(r.Context()); got != test.want {
|
||||||
|
t.Errorf("got %s want %s", got, test.want)
|
||||||
|
}
|
||||||
|
})).ServeHTTP(w, r)
|
||||||
|
if strings.Join(w.Header().Values("Vary"), ",") != "Origin,Accept-Language" {
|
||||||
|
t.Fatal(w.Header())
|
||||||
|
}
|
||||||
|
if test.explicit != "" && !strings.Contains(w.Header().Get("Cache-Control"), "private") {
|
||||||
|
t.Fatal(w.Header())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
custom, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { return []string{"fr"} }))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := custom.Apply(w, httptest.NewRequest("GET", "/", nil))
|
||||||
|
if i18n.Locale(r.Context()) != "fr" || w.Header().Get("Cache-Control") != "private" {
|
||||||
|
t.Fatal(r, w.Header())
|
||||||
|
}
|
||||||
|
broken, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { panic("preference lookup failed") }))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r = broken.Apply(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||||
|
if i18n.Locale(r.Context()) != "en" {
|
||||||
|
t.Fatal("resolver panic must preserve default selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
122
i18n/i18ntool/extract.go
Normal file
122
i18n/i18ntool/extract.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package i18ntool provides conservative, syntax-only Go key extraction.
|
||||||
|
package i18ntool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Reference struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
}
|
||||||
|
type Finding struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
}
|
||||||
|
type Extraction struct {
|
||||||
|
References []Reference `json:"references"`
|
||||||
|
Findings []Finding `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractGo reads literal i18n.Ref expressions, including import aliases. It
|
||||||
|
// neither executes project code nor claims to resolve dynamic keys or wrappers.
|
||||||
|
// Render/RenderContext calls without a literal reference are reported for review.
|
||||||
|
func ExtractGo(filename string, source []byte) (Extraction, error) {
|
||||||
|
out := Extraction{References: []Reference{}, Findings: []Finding{}}
|
||||||
|
fs := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fs, filename, source, 0)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
aliases := map[string]bool{}
|
||||||
|
for _, imp := range file.Imports {
|
||||||
|
path, _ := strconv.Unquote(imp.Path.Value)
|
||||||
|
if path == "git.yuntuo.hk/k2safe/go-omni/i18n" {
|
||||||
|
name := "i18n"
|
||||||
|
if imp.Name != nil {
|
||||||
|
name = imp.Name.Name
|
||||||
|
}
|
||||||
|
if name == "." {
|
||||||
|
out.Findings = append(out.Findings, Finding{filename, fs.Position(imp.Pos()).Line, "dot import requires manual extraction"})
|
||||||
|
} else {
|
||||||
|
aliases[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(aliases) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
lit, ok := n.(*ast.CompositeLit)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||||
|
if !ok || sel.Sel.Name != "Ref" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
pkg, ok := sel.X.(*ast.Ident)
|
||||||
|
if !ok || !aliases[pkg.Name] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
values := map[string]string{}
|
||||||
|
valid := true
|
||||||
|
for i, el := range lit.Elts {
|
||||||
|
key := ""
|
||||||
|
value := el
|
||||||
|
if kv, ok := el.(*ast.KeyValueExpr); ok {
|
||||||
|
id, ok := kv.Key.(*ast.Ident)
|
||||||
|
if !ok {
|
||||||
|
valid = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = id.Name
|
||||||
|
value = kv.Value
|
||||||
|
} else if i == 0 {
|
||||||
|
key = "Namespace"
|
||||||
|
} else if i == 1 {
|
||||||
|
key = "Key"
|
||||||
|
} else {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
s, ok := value.(*ast.BasicLit)
|
||||||
|
if !ok || s.Kind != token.STRING {
|
||||||
|
valid = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, e := strconv.Unquote(s.Value)
|
||||||
|
if e != nil {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
values[key] = v
|
||||||
|
}
|
||||||
|
line := fs.Position(lit.Pos()).Line
|
||||||
|
if valid && values["Namespace"] != "" && values["Key"] != "" {
|
||||||
|
out.References = append(out.References, Reference{values["Namespace"], values["Key"], filename, line})
|
||||||
|
} else {
|
||||||
|
out.Findings = append(out.Findings, Finding{filename, line, "dynamic or incomplete message reference"})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||||
|
if !ok || sel.Sel.Name != "Render" && sel.Sel.Name != "RenderContext" || len(call.Args) != 3 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := call.Args[1].(*ast.CompositeLit); !ok {
|
||||||
|
out.Findings = append(out.Findings, Finding{filename, fs.Position(call.Pos()).Line, "nonliteral Render reference; verify manually (syntax-only extraction)"})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
14
i18n/i18ntool/extract_test.go
Normal file
14
i18n/i18ntool/extract_test.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package i18ntool_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAliasesLiteralsAndDynamicDiagnostics(t *testing.T) {
|
||||||
|
source := []byte("package demo\nimport tr \"git.yuntuo.hk/k2safe/go-omni/i18n\"\nfunc f(){ b.Render(\"en\",tr.Ref{Namespace:\"forms\",Key:\"button\"},nil); b.Render(\"en\",dynamic,nil); _=tr.Ref{Namespace:\"forms\",Key:name} }\n")
|
||||||
|
result, err := i18ntool.ExtractGo("demo.go", source)
|
||||||
|
if err != nil || len(result.References) != 1 || len(result.Findings) != 2 || result.References[0].Namespace != "forms" {
|
||||||
|
t.Fatal(result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
i18n/load.go
Normal file
143
i18n/load.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecodeJSON rejects unknown fields, duplicate keys and excessive nesting.
|
||||||
|
// It never expands environment references or evaluates message text.
|
||||||
|
func DecodeJSON(data []byte, dst any) error {
|
||||||
|
if len(data) > 1<<20 || !utf8.Valid(data) {
|
||||||
|
return fmt.Errorf("i18n: document exceeds 1 MiB or is not UTF-8")
|
||||||
|
}
|
||||||
|
scan := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
scan.UseNumber()
|
||||||
|
nodes := 0
|
||||||
|
var walk func(int) error
|
||||||
|
walk = func(depth int) error {
|
||||||
|
nodes++
|
||||||
|
if depth > 32 || nodes > 100000 {
|
||||||
|
return fmt.Errorf("i18n: JSON nesting or node limit exceeded")
|
||||||
|
}
|
||||||
|
t, err := scan.Token()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("i18n: invalid JSON")
|
||||||
|
}
|
||||||
|
d, ok := t.(json.Delim)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch d {
|
||||||
|
case '{':
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for scan.More() {
|
||||||
|
k, e := scan.Token()
|
||||||
|
if e != nil {
|
||||||
|
return fmt.Errorf("i18n: invalid JSON object")
|
||||||
|
}
|
||||||
|
key, ok := k.(string)
|
||||||
|
if !ok || seen[key] {
|
||||||
|
return fmt.Errorf("i18n: duplicate JSON key")
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if err := walk(depth + 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case '[':
|
||||||
|
for scan.More() {
|
||||||
|
if err := walk(depth + 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("i18n: invalid JSON delimiter")
|
||||||
|
}
|
||||||
|
_, err = scan.Token()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := walk(0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := scan.Token(); err != io.EOF {
|
||||||
|
return fmt.Errorf("i18n: expected one JSON value")
|
||||||
|
}
|
||||||
|
d := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
d.UseNumber()
|
||||||
|
d.DisallowUnknownFields()
|
||||||
|
if err := d.Decode(dst); err != nil {
|
||||||
|
return fmt.Errorf("i18n: invalid document schema")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFS reads exactly the explicitly listed files; it never scans directories.
|
||||||
|
func LoadFS(source fs.FS, paths ...string) ([]Document, error) {
|
||||||
|
if source == nil || len(paths) > 1024 {
|
||||||
|
return nil, fmt.Errorf("i18n: filesystem required; at most 1024 files")
|
||||||
|
}
|
||||||
|
docs := make([]Document, 0, len(paths))
|
||||||
|
total := 0
|
||||||
|
for _, path := range paths {
|
||||||
|
if !fs.ValidPath(path) {
|
||||||
|
return nil, fmt.Errorf("i18n: invalid resource path")
|
||||||
|
}
|
||||||
|
f, err := source.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("i18n: cannot open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
f.Close()
|
||||||
|
return nil, fmt.Errorf("i18n: resource must be a regular file: %s", path)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total += len(data)
|
||||||
|
if total > 4<<20 {
|
||||||
|
return nil, fmt.Errorf("i18n: resources exceed 4 MiB")
|
||||||
|
}
|
||||||
|
var doc Document
|
||||||
|
if err := DecodeJSON(data, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay replaces whole messages, never individual branches or parameter
|
||||||
|
// schemas. The returned document is detached and must still pass New validation.
|
||||||
|
func Overlay(base, override Document) (Document, error) {
|
||||||
|
l1, e1 := canonical(base.Locale)
|
||||||
|
l2, e2 := canonical(override.Locale)
|
||||||
|
if e1 != nil || e2 != nil || l1 != l2 || base.Namespace != override.Namespace || base.Version != Version || override.Version != Version {
|
||||||
|
return Document{}, fmt.Errorf("i18n: overlay requires matching version, locale and namespace")
|
||||||
|
}
|
||||||
|
// A bounded JSON round trip also rejects cyclic Go values before cloning.
|
||||||
|
data, err := json.Marshal([]Document{base, override})
|
||||||
|
if err != nil || len(data) > 1<<20 {
|
||||||
|
return Document{}, fmt.Errorf("i18n: invalid or oversized overlay")
|
||||||
|
}
|
||||||
|
var docs []Document
|
||||||
|
if err := DecodeJSON(data, &docs); err != nil {
|
||||||
|
return Document{}, err
|
||||||
|
}
|
||||||
|
out := docs[0]
|
||||||
|
out.Locale = l1
|
||||||
|
if out.Messages == nil {
|
||||||
|
out.Messages = map[string]Message{}
|
||||||
|
}
|
||||||
|
for key, m := range docs[1].Messages {
|
||||||
|
out.Messages[key] = m
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
221
i18n/pattern.go
Normal file
221
i18n/pattern.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
var paramName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`)
|
||||||
|
var integerText = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
|
||||||
|
|
||||||
|
type part struct{ text, param string }
|
||||||
|
type compiled struct {
|
||||||
|
parts []part
|
||||||
|
selectKey, pluralKey string
|
||||||
|
cases map[string]*compiled
|
||||||
|
}
|
||||||
|
type budget struct{ nodes, bytes int }
|
||||||
|
|
||||||
|
func compile(p Pattern, schema map[string]Kind, used map[string]bool, b *budget, depth int) (*compiled, error) {
|
||||||
|
b.nodes++
|
||||||
|
b.bytes += len(p.Text)
|
||||||
|
if depth > 24 || b.nodes > 100000 || b.bytes > 4<<20 || len(p.Text) > 64<<10 || !utf8.ValidString(p.Text) {
|
||||||
|
return nil, &Diagnostic{Kind: "resource", Problem: "pattern exceeds limits or contains invalid UTF-8"}
|
||||||
|
}
|
||||||
|
c := &compiled{selectKey: p.Select, pluralKey: p.Plural}
|
||||||
|
if p.Select != "" || p.Plural != "" {
|
||||||
|
if p.Select != "" && p.Plural != "" || p.Text != "" || p.Literal || len(p.Cases) == 0 || len(p.Cases) > 128 {
|
||||||
|
return nil, &Diagnostic{Kind: "pattern", Problem: "expected one select/plural branch with cases"}
|
||||||
|
}
|
||||||
|
key := p.Select
|
||||||
|
if key == "" {
|
||||||
|
key = p.Plural
|
||||||
|
}
|
||||||
|
kind, ok := schema[key]
|
||||||
|
if !ok || p.Plural != "" && kind != Integer && kind != Number || p.Select != "" && kind != String && kind != Boolean {
|
||||||
|
return nil, &Diagnostic{Kind: "parameter", Param: key, Problem: "branch requires a declared parameter of the appropriate type"}
|
||||||
|
}
|
||||||
|
used[key] = true
|
||||||
|
if _, ok := p.Cases["other"]; !ok {
|
||||||
|
return nil, &Diagnostic{Kind: "pattern", Problem: "branch requires other"}
|
||||||
|
}
|
||||||
|
c.cases = make(map[string]*compiled, len(p.Cases))
|
||||||
|
for _, name := range sortedKeys(p.Cases) {
|
||||||
|
if !validID(name) {
|
||||||
|
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid case name"}
|
||||||
|
}
|
||||||
|
if p.Plural != "" && !pluralCase(name) {
|
||||||
|
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid plural category or exact integer case"}
|
||||||
|
}
|
||||||
|
child, err := compile(p.Cases[name], schema, used, b, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.cases[name] = child
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if p.Cases != nil {
|
||||||
|
return nil, &Diagnostic{Kind: "pattern", Problem: "cases require select or plural"}
|
||||||
|
}
|
||||||
|
if p.Literal {
|
||||||
|
c.parts = []part{{text: p.Text}}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
var literal strings.Builder
|
||||||
|
flush := func() {
|
||||||
|
if literal.Len() > 0 {
|
||||||
|
c.parts = append(c.parts, part{text: literal.String()})
|
||||||
|
literal.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < len(p.Text); {
|
||||||
|
ch := p.Text[i]
|
||||||
|
if ch != '{' && ch != '}' {
|
||||||
|
literal.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 < len(p.Text) && p.Text[i+1] == ch {
|
||||||
|
literal.WriteByte(ch)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '}' {
|
||||||
|
return nil, &Diagnostic{Kind: "pattern", Problem: "unescaped closing brace"}
|
||||||
|
}
|
||||||
|
end := strings.IndexByte(p.Text[i+1:], '}')
|
||||||
|
if end < 0 {
|
||||||
|
return nil, &Diagnostic{Kind: "pattern", Problem: "unclosed placeholder"}
|
||||||
|
}
|
||||||
|
name := p.Text[i+1 : i+1+end]
|
||||||
|
if _, ok := schema[name]; !ok {
|
||||||
|
return nil, &Diagnostic{Kind: "parameter", Param: name, Problem: "undeclared placeholder"}
|
||||||
|
}
|
||||||
|
used[name] = true
|
||||||
|
flush()
|
||||||
|
c.parts = append(c.parts, part{param: name})
|
||||||
|
i += end + 2
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralCase(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case "zero", "one", "two", "few", "many", "other":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(s, "=") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s[1:], 10, 64)
|
||||||
|
return err == nil && strconv.FormatInt(n, 10) == s[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalar(kind Kind, value any) (string, error) {
|
||||||
|
bad := func() (string, error) {
|
||||||
|
return "", &Diagnostic{Kind: "parameter", Problem: "wrong type, range, or non-finite value"}
|
||||||
|
}
|
||||||
|
if value == nil {
|
||||||
|
return bad()
|
||||||
|
}
|
||||||
|
v := reflect.ValueOf(value)
|
||||||
|
switch kind {
|
||||||
|
case String:
|
||||||
|
if v.Kind() == reflect.String && v.Type() != reflect.TypeOf(json.Number("")) && utf8.ValidString(v.String()) && len(v.String()) <= 64<<10 {
|
||||||
|
return v.String(), nil
|
||||||
|
}
|
||||||
|
case Boolean:
|
||||||
|
if v.Kind() == reflect.Bool {
|
||||||
|
return strconv.FormatBool(v.Bool()), nil
|
||||||
|
}
|
||||||
|
case Integer:
|
||||||
|
if n, ok := value.(json.Number); ok {
|
||||||
|
x, e := n.Int64()
|
||||||
|
if e == nil {
|
||||||
|
return strconv.FormatInt(x, 10), nil
|
||||||
|
}
|
||||||
|
return bad()
|
||||||
|
}
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return strconv.FormatInt(v.Int(), 10), nil
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
if v.Uint() <= math.MaxInt64 {
|
||||||
|
return strconv.FormatUint(v.Uint(), 10), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case Number:
|
||||||
|
var n float64
|
||||||
|
if j, ok := value.(json.Number); ok {
|
||||||
|
var e error
|
||||||
|
n, e = j.Float64()
|
||||||
|
if e != nil {
|
||||||
|
return bad()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
n = v.Float()
|
||||||
|
default:
|
||||||
|
return bad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !math.IsNaN(n) && !math.IsInf(n, 0) {
|
||||||
|
if n == 0 {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
// ECMAScript's plain/scientific thresholds, shared with the browser runtime.
|
||||||
|
format := byte('f')
|
||||||
|
if math.Abs(n) < 1e-6 || math.Abs(n) >= 1e21 {
|
||||||
|
format = 'e'
|
||||||
|
}
|
||||||
|
s := strconv.FormatFloat(n, format, -1, 64)
|
||||||
|
s = strings.ReplaceAll(strings.ReplaceAll(s, "e-0", "e-"), "e+0", "e+")
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bad()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *compiled) render(tag language.Tag, values map[string]string) (string, error) {
|
||||||
|
if c.cases != nil {
|
||||||
|
key := values[c.selectKey]
|
||||||
|
if c.pluralKey != "" {
|
||||||
|
n := values[c.pluralKey]
|
||||||
|
key = "=" + n
|
||||||
|
if _, ok := c.cases[key]; !ok {
|
||||||
|
var err error
|
||||||
|
key, err = category(tag, n)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := c.cases[key]
|
||||||
|
if next == nil {
|
||||||
|
next = c.cases["other"]
|
||||||
|
}
|
||||||
|
return next.render(tag, values)
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
for _, p := range c.parts {
|
||||||
|
value := p.text
|
||||||
|
if p.param != "" {
|
||||||
|
value = values[p.param]
|
||||||
|
}
|
||||||
|
if out.Len()+len(value) > 1<<20 {
|
||||||
|
return "", &Diagnostic{Kind: "resource", Problem: "rendered message exceeds 1 MiB"}
|
||||||
|
}
|
||||||
|
out.WriteString(value)
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
196
i18n/plural.go
Normal file
196
i18n/plural.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluralRulesVersion pins both runtimes to the same Unicode data. Changing it
|
||||||
|
// is a presentation compatibility change, independent of message format Version.
|
||||||
|
const PluralRulesVersion = "cldr-46.1"
|
||||||
|
|
||||||
|
//go:embed cldr/plurals.json
|
||||||
|
var pluralData []byte
|
||||||
|
|
||||||
|
//go:embed cldr/LICENSE
|
||||||
|
var unicodeLicense string
|
||||||
|
|
||||||
|
type pluralRange struct{ lo, hi *big.Int }
|
||||||
|
type pluralCondition struct {
|
||||||
|
operand byte
|
||||||
|
modulus *big.Int
|
||||||
|
negate bool
|
||||||
|
ranges []pluralRange
|
||||||
|
}
|
||||||
|
type pluralRule [][]pluralCondition // OR of AND groups, per the CLDR grammar.
|
||||||
|
type pluralSet struct {
|
||||||
|
text map[string]string
|
||||||
|
rules map[string]pluralRule
|
||||||
|
}
|
||||||
|
|
||||||
|
var pluralConditionSyntax = regexp.MustCompile(`^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$`)
|
||||||
|
var cardinalRules = loadCardinalRules()
|
||||||
|
|
||||||
|
func bigDecimal(s string) *big.Int {
|
||||||
|
if s == "" {
|
||||||
|
return new(big.Int)
|
||||||
|
}
|
||||||
|
n, ok := new(big.Int).SetString(s, 10)
|
||||||
|
if !ok {
|
||||||
|
panic("i18n: invalid embedded plural integer")
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
func parsePluralRule(text string) (pluralRule, error) {
|
||||||
|
var result pluralRule
|
||||||
|
if len(text) > 2048 {
|
||||||
|
return nil, fmt.Errorf("plural rule too large")
|
||||||
|
}
|
||||||
|
for _, or := range strings.Split(text, " or ") {
|
||||||
|
var group []pluralCondition
|
||||||
|
for _, and := range strings.Split(or, " and ") {
|
||||||
|
parts := pluralConditionSyntax.FindStringSubmatch(strings.TrimSpace(and))
|
||||||
|
if parts == nil {
|
||||||
|
return nil, fmt.Errorf("invalid CLDR plural condition")
|
||||||
|
}
|
||||||
|
c := pluralCondition{operand: parts[1][0], negate: parts[3] == "!="}
|
||||||
|
if parts[2] != "" {
|
||||||
|
c.modulus = bigDecimal(parts[2])
|
||||||
|
}
|
||||||
|
for _, r := range strings.Split(parts[4], ",") {
|
||||||
|
lo, hi, has := strings.Cut(strings.TrimSpace(r), "..")
|
||||||
|
if !has {
|
||||||
|
hi = lo
|
||||||
|
}
|
||||||
|
low, high := bigDecimal(lo), bigDecimal(hi)
|
||||||
|
if low.Cmp(high) > 0 {
|
||||||
|
return nil, fmt.Errorf("reversed plural range")
|
||||||
|
}
|
||||||
|
c.ranges = append(c.ranges, pluralRange{low, high})
|
||||||
|
}
|
||||||
|
group = append(group, c)
|
||||||
|
}
|
||||||
|
result = append(result, group)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCardinalRules() map[string]pluralSet {
|
||||||
|
var data struct {
|
||||||
|
Supplemental struct {
|
||||||
|
Rules map[string]map[string]string `json:"plurals-type-cardinal"`
|
||||||
|
} `json:"supplemental"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pluralData, &data); err != nil {
|
||||||
|
panic("i18n: invalid embedded CLDR data")
|
||||||
|
}
|
||||||
|
out := map[string]pluralSet{}
|
||||||
|
for locale, entries := range data.Supplemental.Rules {
|
||||||
|
set := pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
|
||||||
|
for name, raw := range entries {
|
||||||
|
category := strings.TrimPrefix(name, "pluralRule-count-")
|
||||||
|
text, _, _ := strings.Cut(raw, "@")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if category == "other" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule, err := parsePluralRule(text)
|
||||||
|
if err != nil {
|
||||||
|
panic("i18n: unsupported embedded CLDR rule for " + locale)
|
||||||
|
}
|
||||||
|
set.text[category] = text
|
||||||
|
set.rules[category] = rule
|
||||||
|
}
|
||||||
|
out[strings.ReplaceAll(locale, "_", "-")] = set
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func rulesFor(tag language.Tag) pluralSet {
|
||||||
|
for {
|
||||||
|
if rules, ok := cardinalRules[tag.String()]; ok {
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
if tag == language.Und {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tag = tag.Parent()
|
||||||
|
}
|
||||||
|
return pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// operands expands the canonical numeric string exactly, avoiding any float
|
||||||
|
// conversion for int64 and any browser rounding of tiny fractional operands.
|
||||||
|
func operands(text string) (map[byte]*big.Int, bool) {
|
||||||
|
text = strings.TrimPrefix(text, "-")
|
||||||
|
mantissa, exponent, has := strings.Cut(text, "e")
|
||||||
|
shift := 0
|
||||||
|
if has {
|
||||||
|
shift, _ = strconv.Atoi(exponent)
|
||||||
|
}
|
||||||
|
whole, fraction, _ := strings.Cut(mantissa, ".")
|
||||||
|
digits := whole + fraction
|
||||||
|
point := len(whole) + shift
|
||||||
|
switch {
|
||||||
|
case point <= 0:
|
||||||
|
whole = "0"
|
||||||
|
fraction = strings.Repeat("0", -point) + digits
|
||||||
|
case point >= len(digits):
|
||||||
|
whole = digits + strings.Repeat("0", point-len(digits))
|
||||||
|
fraction = ""
|
||||||
|
default:
|
||||||
|
whole = digits[:point]
|
||||||
|
fraction = digits[point:]
|
||||||
|
}
|
||||||
|
tail := strings.TrimRight(fraction, "0")
|
||||||
|
i, f := bigDecimal(whole), bigDecimal(fraction)
|
||||||
|
return map[byte]*big.Int{'n': i, 'i': i, 'v': big.NewInt(int64(len(fraction))), 'w': big.NewInt(int64(len(tail))), 'f': f, 't': bigDecimal(tail), 'c': new(big.Int), 'e': new(big.Int)}, f.Sign() != 0
|
||||||
|
}
|
||||||
|
func (rule pluralRule) matches(values map[byte]*big.Int, fractional bool) bool {
|
||||||
|
for _, group := range rule {
|
||||||
|
all := true
|
||||||
|
for _, condition := range group {
|
||||||
|
value := values[condition.operand]
|
||||||
|
if condition.modulus != nil {
|
||||||
|
value = new(big.Int).Mod(value, condition.modulus)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
if condition.operand != 'n' || !fractional {
|
||||||
|
for _, r := range condition.ranges {
|
||||||
|
if value.Cmp(r.lo) >= 0 && value.Cmp(r.hi) <= 0 {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if condition.negate {
|
||||||
|
found = !found
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
all = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func category(tag language.Tag, text string) (string, error) {
|
||||||
|
values, fractional := operands(text)
|
||||||
|
rules := rulesFor(tag).rules
|
||||||
|
for _, category := range []string{"zero", "one", "two", "few", "many"} {
|
||||||
|
if rule, ok := rules[category]; ok && rule.matches(values, fractional) {
|
||||||
|
return category, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "other", nil
|
||||||
|
}
|
||||||
61
i18n/plural_test.go
Normal file
61
i18n/plural_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPinnedCLDRSamples(t *testing.T) {
|
||||||
|
var data struct {
|
||||||
|
Supplemental struct {
|
||||||
|
Rules map[string]map[string]string `json:"plurals-type-cardinal"`
|
||||||
|
} `json:"supplemental"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pluralData, &data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for locale, entries := range data.Supplemental.Rules {
|
||||||
|
for name, raw := range entries {
|
||||||
|
want := strings.TrimPrefix(name, "pluralRule-count-")
|
||||||
|
for _, section := range strings.Split(raw, "@")[1:] {
|
||||||
|
_, samples, ok := strings.Cut(section, " ")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sample := range strings.Split(samples, ",") {
|
||||||
|
for _, number := range strings.Split(strings.TrimSpace(sample), "~") {
|
||||||
|
if number == "" || strings.ContainsAny(number, "…ce") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values, fractional := operands(number)
|
||||||
|
got := "other"
|
||||||
|
for _, cat := range []string{"zero", "one", "two", "few", "many"} {
|
||||||
|
if rule, ok := cardinalRules[locale].rules[cat]; ok && rule.matches(values, fractional) {
|
||||||
|
got = cat
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s %s: got %s want %s", locale, number, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count < 5000 {
|
||||||
|
t.Fatalf("too few CLDR samples: %d", count)
|
||||||
|
}
|
||||||
|
t.Logf("validated %d upstream cardinal samples", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleParserRejectsUnsupportedSyntax(t *testing.T) {
|
||||||
|
for _, text := range []string{"n = 1; run()", "n % 0 = 1", "n = 5..1", "unknown = 1"} {
|
||||||
|
if _, err := parsePluralRule(text); err == nil {
|
||||||
|
t.Fatal(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
i18n/types.go
Normal file
120
i18n/types.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Package i18n provides immutable, instance-scoped message catalogs. It has no
|
||||||
|
// dependency on HTTP, application configuration, business errors or storage.
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Version = 1
|
||||||
|
|
||||||
|
// Kind describes portable message arguments, independently of Go identifiers.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
String Kind = "string"
|
||||||
|
Boolean Kind = "boolean"
|
||||||
|
Integer Kind = "integer" // Signed int64; browser callers may use bigint/string.
|
||||||
|
Number Kind = "number" // Finite IEEE-754 float64.
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pattern is a text leaf or a select/plural branch. Branches require "other".
|
||||||
|
// Text uses {name}; {{ and }} escape braces. Literal disables interpolation.
|
||||||
|
// Plural is cardinal, with optional exact integer cases such as "=0".
|
||||||
|
type Pattern struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Literal bool `json:"literal,omitempty"`
|
||||||
|
Select string `json:"select,omitempty"`
|
||||||
|
Plural string `json:"plural,omitempty"`
|
||||||
|
Cases map[string]Pattern `json:"cases,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Params map[string]Kind `json:"params,omitempty"`
|
||||||
|
Value Pattern `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) UnmarshalJSON(data []byte) error {
|
||||||
|
type plain Message
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if value, ok := raw["value"]; !ok || string(value) == "null" {
|
||||||
|
return fmt.Errorf("i18n: message requires a value")
|
||||||
|
}
|
||||||
|
for key := range raw {
|
||||||
|
if key != "value" && key != "params" {
|
||||||
|
return fmt.Errorf("i18n: unknown message field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var value plain
|
||||||
|
// Pattern's fields are checked by the strict decoder, including nested cases.
|
||||||
|
if err := DecodeJSON(data, &value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*m = Message(value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document is the versioned interchange format. Source-locale documents own
|
||||||
|
// parameter schemas; translations inherit them and may repeat identical schemas.
|
||||||
|
type Document struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
Messages map[string]Message `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config distinguishes source text from the default selection and enabled locales.
|
||||||
|
// Fallbacks are explicit, ordered edges; cycles are rejected. The default locale
|
||||||
|
// must be enabled. The source locale can remain a fallback-only language.
|
||||||
|
type Config struct {
|
||||||
|
SourceLocale string `json:"sourceLocale"`
|
||||||
|
DefaultLocale string `json:"defaultLocale"`
|
||||||
|
Locales []string `json:"locales"`
|
||||||
|
Fallbacks map[string][]string `json:"fallbacks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ref struct{ Namespace, Key string }
|
||||||
|
|
||||||
|
// Result reports the actual dictionary language, which may differ from Requested.
|
||||||
|
type Result struct {
|
||||||
|
Text string
|
||||||
|
Requested string
|
||||||
|
Locale string
|
||||||
|
Fallback bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic deliberately excludes argument values and message contents.
|
||||||
|
type Diagnostic struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
|
Param string `json:"param,omitempty"`
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Diagnostic) Error() string {
|
||||||
|
return fmt.Sprintf("i18n: %s namespace=%q key=%q locale=%q param=%q: %s", d.Kind, d.Namespace, d.Key, d.Locale, d.Param, d.Problem)
|
||||||
|
}
|
||||||
|
|
||||||
|
type localeKey struct{}
|
||||||
|
|
||||||
|
// WithLocale records a preference; rendering still matches it against the bundle.
|
||||||
|
func WithLocale(ctx context.Context, locale string) context.Context {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, localeKey{}, locale)
|
||||||
|
}
|
||||||
|
func Locale(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value, _ := ctx.Value(localeKey{}).(string)
|
||||||
|
return value
|
||||||
|
}
|
||||||
25
internal/httplimits/limits.go
Normal file
25
internal/httplimits/limits.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Package httplimits shares the application body policy across transports.
|
||||||
|
package httplimits
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultBodyBytes int64 = 8 << 20
|
||||||
|
MaxBodyBytes int64 = 1 << 40
|
||||||
|
)
|
||||||
|
|
||||||
|
// RejectBody tells cooperating transports not to drain a rejected body. The
|
||||||
|
// semantic failure must survive encoders that map business failures to HTTP 200.
|
||||||
|
func RejectBody(w http.ResponseWriter) {
|
||||||
|
for {
|
||||||
|
if reject, ok := w.(interface{ RequestBodyTooLarge() }); ok {
|
||||||
|
reject.RequestBodyTooLarge()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unwrap, ok := w.(interface{ Unwrap() http.ResponseWriter })
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w = unwrap.Unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,14 +35,16 @@ c.OnDrain("jobs", jobs.Wait)
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
shutdownTimeout: 10s # HTTP 排空时限,保持原语义
|
shutdownTimeout: 10s # HTTP 排空时限
|
||||||
|
http:
|
||||||
|
shutdownCancelTimeout: 5s # HTTP 取消后的收尾窗口
|
||||||
lifecycle:
|
lifecycle:
|
||||||
stopTimeout: 5s
|
stopTimeout: 5s
|
||||||
drainTimeout: 10s
|
drainTimeout: 10s
|
||||||
cancelTimeout: 5s
|
cancelTimeout: 5s
|
||||||
```
|
```
|
||||||
|
|
||||||
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。可用 `GO_OMNI_LIFECYCLE_STOP_TIMEOUT`、`GO_OMNI_LIFECYCLE_DRAIN_TIMEOUT`、`GO_OMNI_LIFECYCLE_CANCEL_TIMEOUT` 覆盖;生成配置还支持完整的 `GO_OMNI_SERVER_LIFECYCLE_*` 路径,别名优先。配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
|
这些是各阶段的整体预算,不是每个回调分别等待这么久。零使用默认值,负值在启动前拒绝。环境变量仅通过配置文件显式引用,例如 `cancelTimeout: "${WORK_CANCEL_TIMEOUT:-5s}"`,不存在自动覆盖或别名优先。后台生命周期配置应在 config 中设置,不放在创建 App 时才执行的 AppOptions 中。
|
||||||
|
|
||||||
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
|
阶段超时会取消阶段 context 和 WorkContext,再给尚未返回的钩子一个 cancelTimeout 收尾窗口。若所有钩子都返回 nil,可以释放依赖,但仍返回超时错误,进程非零退出。task.Wait 收到取消会取消其任务并返回错误;这不证明全部任务已退出,因此仍按以下保守规则处理。
|
||||||
|
|
||||||
@@ -50,10 +52,24 @@ Stop 超时且仍未返回的组件,不会再并发调用它同名的 Drain;
|
|||||||
|
|
||||||
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
|
钩子返回错误、panic、缺少匹配 Drain,或窗口结束后仍有钩子未返回时,Runtime 返回可由 `errors.Is(err, lifecycle.ErrResourcesRetained)` 判断的错误,**跳过全部 OnClose**;生成入口也保留借用的日志实例并非零退出,由进程退出释放剩余 OS 资源。不会悄悄重试 Close,也不会让晚到的后台结果触发自动关闭。错误链保留初始化/监听/后台故障及具体 HookError 的阶段、名称和原始原因。
|
||||||
|
|
||||||
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;本版阶段时限不会强制中断资源 Close。HTTP 超时沿用现有传输层规则,业务处理器仍须响应请求取消。
|
手写入口必须处理这个错误并终止该服务,不能继续运行半关闭的 Runtime;嵌入式宿主需自行安排隔离/终止策略。Go 不能强杀不响应取消的 goroutine,有限等待与保证它已经结束不能同时无条件成立。OnClose 仍要求自身能返回;阶段时限不会强制中断资源 Close。HTTP 超时也必须经过下述完成屏障,业务处理器仍须响应请求取消。
|
||||||
|
|
||||||
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
|
手写项目使用 `lifecycle.NewRuntimeWithConfig(ctx, cfg)`;完成 HTTP 排空后调用 `runtime.Finish(runErr)`。库本身不执行 os.Exit。
|
||||||
|
|
||||||
|
### HTTP 请求与依赖的关闭屏障
|
||||||
|
|
||||||
|
App 统计已进入且尚未退出的整个处理链,包括中间件、响应编码和 defer;停止接收与登记请求共用同步屏障。关闭连接、取消 context、HTTP/2 session 结束均不等于处理链结束。
|
||||||
|
|
||||||
|
`RunContext` / `ServeContext` 先按 `shutdownTimeout` 排空,超时取消并关闭连接;App 随后按 `http.shutdownCancelTimeout`(零默认 5s)等待处理器完成收尾。收尾成功则执行逆序 OnShutdown,仍保留原排空超时错误。收尾窗口结束时还有处理器运行,则永久返回 `ErrResourcesRetained` 并跳过所有 OnShutdown;后续 Close 或晚到的处理器返回不会触发补做清理。
|
||||||
|
|
||||||
|
`Runtime.Finish(runErr)` 收到该错误后,继续执行后台 OnStop/OnDrain 并取消 WorkContext,但跳过 OnClose;后续 `Runtime.Close` 也保持资源保留。生成入口通过这条错误链保留数据库等共享依赖和借用日志,并非零退出。手写入口必须传递 runErr 给 Finish,并在 `errors.Is(runErr, lifecycle.ErrResourcesRetained)` 时跳过自己拥有的额外资源清理,不能无条件 defer DB.Close/Logger.Close。
|
||||||
|
|
||||||
|
`App.Close` 本身会停止接收、取消请求并等待收尾,适用于外部 HTTP 宿主;正常优雅排空应先由宿主停止监听并 Shutdown,再调用 Close。不要在请求处理器或清理钩子内部调用 Close(会等待自身)。`App`、`Engine`、`NewServer().Handler` 和 `RunWith` 都经过统计;已停止的 App 返回简单 503,不再调用可能使用已关闭依赖的中间件或编码器。App 不支持关闭后重新启动。
|
||||||
|
|
||||||
|
外部 Runner 返回不构成请求完成证明,App 仍执行自己的完成屏障;Runner 负责关闭自己拥有的 listener/连接。原始 `Router().ServeHTTP` 绕过 App。处理器退出后继续运行的自建 goroutine、Hijack/WebSocket 后台工作,必须用 OnStop/OnDrain 注册,框架不会推断这些工作的生命周期。HTTP/2 低层 Shutdown 也会等待独立 handler goroutine;超时返回 error 仍不表示它已退出。
|
||||||
|
|
||||||
|
新行为增加一个有限 HTTP 收尾窗口,调用方总停机预算应计入它。已有生成项目升级框架即可获得 Runtime.Finish 的保护;重新生成可同步入口注释。可运行示例见 [httpSafety](../examples/httpSafety/README.md)。
|
||||||
|
|
||||||
### 升级
|
### 升级
|
||||||
|
|
||||||
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。
|
v0.14.x 受管理项目将 module 和 CLI 一起升级到 v0.15.0-preview,再运行 `go-omni api generate`、`go-omni api generate --check` 和业务测试。生成器更新 main/runtimeGen,保留 svc、Logic 和配置。已有配置不添加新字段也会使用默认时限,需要调整时再编辑 yaml。
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
|
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrResourcesRetained means background quiescence could not be established.
|
// ErrResourcesRetained means HTTP or background quiescence was not established.
|
||||||
// OnClose callbacks were deliberately skipped. Close is not retried; an owning
|
// OnClose callbacks were deliberately skipped. Close is not retried; an owning
|
||||||
// process should exit unsuccessfully instead of continuing with a stopped Runtime.
|
// process should exit unsuccessfully instead of continuing with a stopped Runtime.
|
||||||
var ErrResourcesRetained = errors.New("lifecycle: background shutdown incomplete; shared resources retained")
|
var ErrResourcesRetained = errors.New("lifecycle: shutdown incomplete; shared resources retained")
|
||||||
|
|
||||||
type ShutdownConfig struct {
|
type ShutdownConfig struct {
|
||||||
StopTimeout time.Duration `json:"stopTimeout"`
|
StopTimeout time.Duration `json:"stopTimeout"`
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ func (r *Runtime) OnClose(cleanup func() error) {
|
|||||||
// skips resource callbacks and returns ErrResourcesRetained. Once release is
|
// skips resource callbacks and returns ErrResourcesRetained. Once release is
|
||||||
// safe, all OnClose callbacks are attempted even if one fails or panics.
|
// safe, all OnClose callbacks are attempted even if one fails or panics.
|
||||||
func (r *Runtime) Close() error {
|
func (r *Runtime) Close() error {
|
||||||
|
return r.close(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runtime) close(retain bool) error {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,7 @@ func (r *Runtime) Close() error {
|
|||||||
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
|
drainErr := r.runPhase("drain", eligible, r.shutdown.DrainTimeout)
|
||||||
r.cancelWork()
|
r.cancelWork()
|
||||||
r.closeErr = errors.Join(stopErr, drainErr)
|
r.closeErr = errors.Join(stopErr, drainErr)
|
||||||
if unsafePhase(stopErr) || unsafePhase(drainErr) {
|
if retain || unsafePhase(stopErr) || unsafePhase(drainErr) {
|
||||||
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
|
r.closeErr = errors.Join(r.closeErr, ErrResourcesRetained)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -129,12 +133,14 @@ func runCleanup(fn func() error) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finish closes resources and preserves the operation, cleanup and shutdown
|
// Finish closes resources and preserves the operation, cleanup and shutdown
|
||||||
// errors. Read the final cause only after monitors have been stopped by Close.
|
// errors. An incoming ErrResourcesRetained (for example, unfinished HTTP work)
|
||||||
|
// still runs stop/drain hooks, but permanently skips resource cleanup, including
|
||||||
|
// subsequent Close calls. Read the final cause after stopping the monitors.
|
||||||
func (r *Runtime) Finish(err error) error {
|
func (r *Runtime) Finish(err error) error {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = errors.Join(err, r.Close())
|
err = errors.Join(err, r.close(errors.Is(err, ErrResourcesRetained)))
|
||||||
if cause := r.Lifecycle.Err(); cause != nil && !errors.Is(err, cause) {
|
if cause := r.Lifecycle.Err(); cause != nil && !errors.Is(err, cause) {
|
||||||
err = errors.Join(err, cause)
|
err = errors.Join(err, cause)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,28 @@ func TestRuntimeRetainsMonitorFaultDuringCleanup(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
r.OnClose(func() error { return nil })
|
r.OnClose(func() error { return nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFinishRetainsDependenciesAfterIncompleteHTTPDrain(t *testing.T) {
|
||||||
|
r := NewRuntime(context.Background())
|
||||||
|
var order []string
|
||||||
|
r.OnStop("worker", func(context.Context) error { order = append(order, "stop"); return nil })
|
||||||
|
r.OnDrain("worker", func(context.Context) error { order = append(order, "drain"); return nil })
|
||||||
|
r.OnClose(func() error { t.Error("released dependency while HTTP work remains"); return nil })
|
||||||
|
fault := errors.New("HTTP shutdown timeout")
|
||||||
|
err := r.Finish(errors.Join(fault, ErrResourcesRetained))
|
||||||
|
if !errors.Is(err, fault) || !errors.Is(err, ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(order, []string{"stop", "drain"}) {
|
||||||
|
t.Fatal(order)
|
||||||
|
}
|
||||||
|
if r.WorkContext().Err() == nil {
|
||||||
|
t.Fatal("background work was not canceled")
|
||||||
|
}
|
||||||
|
if err = r.Close(); !errors.Is(err, ErrResourcesRetained) {
|
||||||
|
t.Fatal("deferred Close bypassed retention", err)
|
||||||
|
}
|
||||||
|
if err = r.Finish(nil); !errors.Is(err, ErrResourcesRetained) {
|
||||||
|
t.Fatal("repeat Finish bypassed retention", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
6
omni.go
6
omni.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/cors"
|
"git.yuntuo.hk/k2safe/go-omni/cors"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
"git.yuntuo.hk/k2safe/go-omni/metrics"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
@@ -56,6 +57,11 @@ func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
|
|||||||
|
|
||||||
func WithErrors(catalog apierrors.Catalog) Option { return config.WithErrors(catalog) }
|
func WithErrors(catalog apierrors.Catalog) Option { return config.WithErrors(catalog) }
|
||||||
|
|
||||||
|
func WithLanguagePolicy(policy *httpi18n.Policy) Option { return config.WithLanguagePolicy(policy) }
|
||||||
|
func WithErrorLocalizer(localizer apierrors.Localizer) Option {
|
||||||
|
return config.WithErrorLocalizer(localizer)
|
||||||
|
}
|
||||||
|
|
||||||
func WithResponse(settings response.Config) Option { return config.WithResponse(settings) }
|
func WithResponse(settings response.Config) Option { return config.WithResponse(settings) }
|
||||||
|
|
||||||
func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) }
|
func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) }
|
||||||
|
|||||||
33
reports/httpSafetyAcceptance.md
Normal file
33
reports/httpSafetyAcceptance.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# HTTP 限额与停机安全验收(2026-09-29)
|
||||||
|
|
||||||
|
对应 v0.27.0-preview;历史 v0.26.0-preview 标签保持。框架不包含业务文件规格、数据库实现或应用专属错误码。
|
||||||
|
|
||||||
|
## 行为验收
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| nethttp/native/HTTP2:16 MiB 路由接收 9 MiB | 固定长度与未知长度均成功 |
|
||||||
|
| 默认、路由覆盖、服务硬上限 | 边界值成功,超限进入 BODY_TOO_LARGE;读取中间件也受 reader 限额约束 |
|
||||||
|
| 路由超过服务硬上限 | 启动前拒绝,诊断包含嵌套分组完整路径及两级字节数;外部宿主可调用 ValidateHTTP |
|
||||||
|
| native 提前拒绝上传 | 先发出响应再有界半关闭,避免即时关闭触发 TCP reset;10 轮上传相关 race 回归通过 |
|
||||||
|
| 错误目录将超限映射为 HTTP 200 | 业务码/HTTP 策略保持,不再继续读取被拒绝的请求体;客户端不发送 body 也能及时收到响应 |
|
||||||
|
| 正常停机与取消后收尾 | 处理链和 defer 完成后才运行资源清理;收尾成功仍保留原排空超时错误 |
|
||||||
|
| 不响应取消的处理器 | 有界返回 ErrResourcesRetained,跳过 App 清理;重复 Close、晚到返回不触发清理 |
|
||||||
|
| App/Engine/NewServer/RunWith | 统一跟踪请求;停止后拒绝新请求,不执行依赖项目资源的处理链 |
|
||||||
|
| HTTP/2 客户端断开 | Shutdown 同时等待连接和独立 handler,不把连接结束当成业务结束 |
|
||||||
|
| 真实生成项目 | HTTP 未结束时 Runtime.Finish 仍停止后台工作,但保留客户端与日志;重复 deps.Close 不能绕过保护 |
|
||||||
|
| 可运行示例 | 9 MiB 上传返回 9437184 字节;SIGTERM 后先请求收尾、再关闭资源;排空超时退出码为 1 |
|
||||||
|
|
||||||
|
## 检查
|
||||||
|
|
||||||
|
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:54 个测试包通过,包含真实生成消费者的 race/build。
|
||||||
|
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:54 个测试包通过。随后 native 拒绝连接收尾补充修复,在最终代码上重新执行 `GOTOOLCHAIN=go1.22.12 go test -count=1 ./app ./config ./lifecycle ./httpx ./transport/...`,8 个测试包通过。
|
||||||
|
- `go test -race ./app ./transport/native -run 'Test(UploadPolicy|ConfiguredBody|ApplicationRejectsUnreadBody)' -count=10`:通过。
|
||||||
|
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
|
||||||
|
- 编译并实跑 `examples/httpSafety`,使用随机本机端口与实际 SIGTERM 验证上传和关闭顺序,无外部服务。
|
||||||
|
|
||||||
|
## 兼容边界
|
||||||
|
|
||||||
|
旧 nethttp 手写普通路由新增默认 8 MiB 限额,大请求应显式配置;生成契约默认 1 MiB 保持。取消后的等待默认 5s,需计入服务停机预算。手写资源拥有者须将运行错误传给 Runtime.Finish,并在资源保留信号下跳过自己的额外关闭操作。自建 goroutine、Hijack 后的工作仍须登记后台生命周期。
|
||||||
|
|
||||||
|
自定义 Runner、独立 native/http2.Server、反向代理的额外限额由各自拥有者配置;使用 WithTransport 获取统一装配。请求体、multipart 单文件和幂等缓存限额保持各自用途。已发送的流式响应无法撤回,先完成有界读取再执行业务。
|
||||||
88
reports/i18nAcceptance.md
Normal file
88
reports/i18nAcceptance.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# v0.26 通用国际化验收(2026-09-29)
|
||||||
|
|
||||||
|
交付范围:独立不可变词典、可选 HTTP/错误适配、CLI 检查/提取/导出、TypeScript 类型和独立浏览器运行时。普通消息、错误源文案、启用语言、用户偏好、资源提供方式均由项目维护;示例不依赖数据库或其他项目。
|
||||||
|
|
||||||
|
## 已通过的验证
|
||||||
|
|
||||||
|
环境:macOS / Apple M2 Max,Go 1.27.1,Node.js 22.15.1,TypeScript 5.6.3;最低版本另用 Go 1.22.12 核验。TypeScript 编译器通过 npm 临时缓存使用,没有添加项目 npm 依赖。
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `go test -race ./...` | 54 个有测试的包通过;未启用集成环境变量的生成消费者单独执行如下 |
|
||||||
|
| `go vet ./...`、`go build ./...`、`git diff --check` | 通过 |
|
||||||
|
| `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./cmd/go-omni/apigen -run 'TestI18nGeneratedConsumer\|TestErrorInheritance\|TestErrorParamsConsumer' -count=1 -v` | 3 组真实生成项目生成、重复生成、check 和消费者 race 测试通过 |
|
||||||
|
| Go 1.22.12:`go test ./i18n/... ./app ./errors ./response ./httpx ./config ./cmd/go-omni ./examples/i18n` | 全部通过 |
|
||||||
|
| Go 1.22.12:开启集成测试,`go test ./cmd/go-omni/apigen -run TestI18n -count=1 -v` | translation 元数据边界、真实生成消费者通过 |
|
||||||
|
| `node --test i18n/browser/runtime.test.mjs` | 4 组通过,包含覆盖、并发切换、最新失败/过期请求、坏包与读取上限 |
|
||||||
|
| `TestPinnedCLDRSamples` | 5,777 个上游普通基数规则样例端点通过;不把省略号和紧凑数字样例计入 |
|
||||||
|
| `TestBrowserConformance`、`TestSourceLocaleCanBeFallbackOnly` | Go 导出包在 Node 中执行,中文/英文/俄文/阿拉伯文/法文、精确 int64、小数、回退与源语言未启用通过;cmn 别名及私用标签不由浏览器改写 |
|
||||||
|
| `TestTypeScriptConsumer`(PATH 提供 tsc) | strict / ES2022 / NodeNext 编译通过;缺参、错类型、多余参数、未知键/命名空间必须产生编译诊断 |
|
||||||
|
| `go test ./i18n -run '^$' -fuzz FuzzDecodeJSON -fuzztime 10s` | 419,121 次执行通过 |
|
||||||
|
| 示例 CLI、词典 check、示例 HTTP 测试 | 通过;示例中英文输出及中文错误 HTTP 409 / code 400001 保持 |
|
||||||
|
|
||||||
|
最后的 TypeScript 类型修正后,重新执行了 `i18n/...`、CLI、示例 race 测试及对应 Go 1.22 验证。核心一致性及类型验证可复现为:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm exec --yes --package typescript@5.6.3 -- go test ./i18n \
|
||||||
|
-run 'TestTypeScriptConsumer|TestPinnedCLDRSamples|TestBrowserConformance|TestSourceLocaleCanBeFallbackOnly' \
|
||||||
|
-count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
原始日志见 [i18nValidation.txt](i18nValidation.txt)。
|
||||||
|
|
||||||
|
## 兼容和隔离覆盖
|
||||||
|
|
||||||
|
- 多 Bundle、多 App、并发请求与多个命名空间不共享可变语言状态;构建输入与导出快照修改不影响实例。
|
||||||
|
- 项目目录和路由独立目录同名错误正确选取各自翻译;固定旧文案的字面花括号保留。
|
||||||
|
- 翻译仅进入展示层。HTTP 200 业务失败、HTTP 409/401/500、int64 业务码、嵌套响应模板、旧 Messages 校验、RawJSON 错误、认证提前返回、panic、缺参数和缓冲桥接均有回归。
|
||||||
|
- 参数值不重新解释为模板,内部 cause 不进入响应;Localizer 或偏好 Resolver panic 不把语言故障传播成新的业务执行结果。
|
||||||
|
- 自定义固定回执由原 Encoder 控制;直接 Text/JSON 等输出不自动改写。自定义 Encoder 可显式采用 PublicMessage。
|
||||||
|
- 本地/共享幂等重放保留首次响应,即使后续请求语言不同也只执行业务一次。
|
||||||
|
- 资源严格校验、循环回退、参数契约不一致、缺键诊断、显式覆盖、CLI 输入不被改写、旧 runtime 能力门槛均有测试。
|
||||||
|
|
||||||
|
## 浏览器实测
|
||||||
|
|
||||||
|
使用独立示例启动本机 `127.0.0.1:8098`,执行了:
|
||||||
|
|
||||||
|
1. 输入测试草稿,从英文切为中文:草稿保留,提交次数为 0。
|
||||||
|
2. 点击提交:次数变为 1。切回英文:草稿保留,次数仍为 1。
|
||||||
|
3. 停止示例服务后再选择中文:显示加载失败,仍停留在英文,草稿和一次提交保留。
|
||||||
|
|
||||||
|
示例服务和临时浏览器页面已关闭。乱序和快速重复选择由浏览器运行时自动测试验证。
|
||||||
|
|
||||||
|
## Go 1.22 发布前兼容性修复
|
||||||
|
|
||||||
|
初轮全仓 Go 1.22 验收暴露了两处旧问题,发布准备阶段已修复:
|
||||||
|
|
||||||
|
- 共享 Go 目录测试工程的 go.mod 从 1.24.0 调整为框架声明支持的 1.22.0,远程验收的同类测试工程同步调整。
|
||||||
|
- JSON 对象/数组结束定界符读取失败时保留底层错误,外层 MaxBytesReader 的限额错误继续被识别为 HTTP 413。新增对象、数组、嵌套结构的 reader cause 回归,原有 limit=0/1/4 测试保留。
|
||||||
|
|
||||||
|
最初发现问题的日志仍保留在 i18nValidation.txt 中,便于追溯;发布前的最终结果单独记录在 i18nReleaseValidation.txt 中。
|
||||||
|
|
||||||
|
## 发布前最终验收
|
||||||
|
|
||||||
|
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:54 个有测试的包通过,包含实际生成消费者;PATH 提供 TypeScript 5.6.3,跨端和类型测试实际执行。
|
||||||
|
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./...`:全仓通过,先前两处失败已消除。
|
||||||
|
- `go vet ./...`、`go build ./...`、浏览器运行时测试、发布脚本语法检查、`git diff --check`:通过。
|
||||||
|
- `python3 scripts/verifyManagedProject.py --report reports/i18nManagedLocal.json`:真实生成项目运行、构建、重复生成、nethttp/native 信号退出、上传下载与故障清理通过;临时进程已清理。[机器记录](i18nManagedLocal.json)。
|
||||||
|
|
||||||
|
完整最终输出见 [i18nReleaseValidation.txt](i18nReleaseValidation.txt)。发布后从空 module 缓存安装固定标签的验收也已通过,见文末;远程报告独立提交,不移动发布标签。
|
||||||
|
|
||||||
|
## 性能与发布边界
|
||||||
|
|
||||||
|
`BenchmarkRender` 对俄文基数复数、22 参数的单条消息,当前机器一次测量为 3,753 ns/op、1,040 B/op、27 allocs/op;这只是开发机样本,不代表 SLA 或与旧实现的性能对比。
|
||||||
|
|
||||||
|
本次按 Go module 标签 `v0.26.0-preview` 发布;浏览器运行时随 module 分发,没有单独发布 npm 包。CLDR 46.1.0 数据随源码和导出包携带许可,启动不下载规则。v1 支持普通文本、命名参数、select 和基数复数;不是完整 ICU MessageFormat,不自动处理金额、日期、时区、RTL 布局或业务字段翻译。
|
||||||
|
|
||||||
|
## 已发布远程产物验收
|
||||||
|
|
||||||
|
`v0.26.0-preview` 标签固定在 `d1a747c36f15d964dd445d661623b68a45892adf`;远程 Go module 校验和为 `h1:9Ao/6+8jdqXf4Ia++rhgorTrYclAgrWgXw3U+AWtB4I=`。发布代码与标签已推送,验收记录独立提交,标签没有移动。
|
||||||
|
|
||||||
|
使用空 module 缓存安装已发布 CLI,10 组真实生成消费者、87 项发布检查通过,耗时 83.76 秒。CLI 与框架固定同版,所有消费者均确认无本地 replace。新检查覆盖下载模块中的独立 i18n、HTTP 错误不变量、源语言仅回退、CLDR 及 Go/浏览器一致性、TypeScript strict 消费者、四个 CLI 子命令和浏览器切换竞态/失败保留。详见 [i18nRemote.json](i18nRemote.json)。
|
||||||
|
|
||||||
|
复验命令(需要 Node.js,TypeScript 由 npm 临时缓存提供):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm exec --yes --package typescript@5.6.3 -- python3 scripts/verifyManagedRelease.py \
|
||||||
|
--version v0.26.0-preview --report reports/i18nRemote.json
|
||||||
|
```
|
||||||
61
reports/i18nManagedLocal.json
Normal file
61
reports/i18nManagedLocal.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"mode": "localSource",
|
||||||
|
"durationSeconds": 11.57,
|
||||||
|
"checks": [
|
||||||
|
"rootGoRun",
|
||||||
|
"rootGoBuild",
|
||||||
|
"stableRegeneration",
|
||||||
|
"checkMainOwnership",
|
||||||
|
"contractChangePreview",
|
||||||
|
"logicSignatureCheckPersists",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"nethttpSIGINT",
|
||||||
|
"nethttpSIGTERM",
|
||||||
|
"nativeSIGINT",
|
||||||
|
"nativeSIGTERM",
|
||||||
|
"listenerReleased",
|
||||||
|
"temporaryProcessesRemoved",
|
||||||
|
"nethttpGeneratedTransfers",
|
||||||
|
"nativeGeneratedTransfers",
|
||||||
|
"malformedMultipart400",
|
||||||
|
"goOwnedErrors",
|
||||||
|
"default404And405"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/apigen/generate.go": "2305b168701c4564f83d680fc62e6f5a3589f4a3d7e78e66d82a6328bf0a6a89",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
|
||||||
|
"cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||||
|
"cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8",
|
||||||
|
"cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb",
|
||||||
|
"cmd/go-omni/apigen/write.go": "7dcdac99760eaa16ec4f729998a2c29c0bf84eefbda229bd5c2d850dd4fc0646",
|
||||||
|
"cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85",
|
||||||
|
"config/config.go": "f8007526a9ebbbc781b2e27e37a49ec768deb568d56846a6ccc62b059a012fd0",
|
||||||
|
"httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d",
|
||||||
|
"httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
|
||||||
|
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-config.go.tmpl": "7638869e0bf47f33b3d0b6f9f64d1413a6beb0cdb986f6d65ec68bf477bfd2e7",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1"
|
||||||
|
}
|
||||||
|
}
|
||||||
194
reports/i18nReleaseValidation.txt
Normal file
194
reports/i18nReleaseValidation.txt
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
=== Go 1.27.1 full race including generated consumers and TypeScript ===
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/audit (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/auth (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache/redis (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 4.178s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 85.649s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 43.902s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/contract 2.065s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/copyx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cors (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/gormx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/migrate (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/mysql (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/errors (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/configuration (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/copying (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/modules (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/platform (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/health (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpc (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 3.128s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/lifecycle (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/logx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/metrics (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/openapi (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/pagination (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/queue/redis (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/resilience (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/response (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/local (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/oss (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/task (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/telemetry (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||||
|
|
||||||
|
=== Go 1.22.12 full suite including generated consumers ===
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/audit (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/auth (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache/redis (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 3.709s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 77.621s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 54.857s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/contract 1.242s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/copyx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cors (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/gormx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/migrate (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/mysql (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/errors (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/configuration (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/copying (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/modules (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/platform (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/health (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpc (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/lifecycle (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/logx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/metrics (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/openapi (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/pagination (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/queue/redis (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/resilience (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/response (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/local (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/oss (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/task (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/telemetry (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp (cached)
|
||||||
|
|
||||||
|
=== go vet ./... ===
|
||||||
|
(no diagnostics)
|
||||||
|
|
||||||
|
=== go build ./... ===
|
||||||
|
(no diagnostics)
|
||||||
|
|
||||||
|
=== Node browser runtime tests ===
|
||||||
|
TAP version 13
|
||||||
|
# Subtest: detached resources, explicit overrides, text arguments and fallback
|
||||||
|
ok 1 - detached resources, explicit overrides, text arguments and fallback
|
||||||
|
---
|
||||||
|
duration_ms: 2.926334
|
||||||
|
type: 'test'
|
||||||
|
...
|
||||||
|
# Subtest: latest selection wins and failures preserve committed state
|
||||||
|
ok 2 - latest selection wins and failures preserve committed state
|
||||||
|
---
|
||||||
|
duration_ms: 0.386666
|
||||||
|
type: 'test'
|
||||||
|
...
|
||||||
|
# Subtest: bad packs cannot commit or change enabled languages/schemas
|
||||||
|
ok 3 - bad packs cannot commit or change enabled languages/schemas
|
||||||
|
---
|
||||||
|
duration_ms: 0.266708
|
||||||
|
type: 'test'
|
||||||
|
...
|
||||||
|
# Subtest: bounded fetching and validation before switching
|
||||||
|
ok 4 - bounded fetching and validation before switching
|
||||||
|
---
|
||||||
|
duration_ms: 12.806416
|
||||||
|
type: 'test'
|
||||||
|
...
|
||||||
|
1..4
|
||||||
|
# tests 4
|
||||||
|
# suites 0
|
||||||
|
# pass 4
|
||||||
|
# fail 0
|
||||||
|
# cancelled 0
|
||||||
|
# skipped 0
|
||||||
|
# todo 0
|
||||||
|
# duration_ms 62.584417
|
||||||
|
|
||||||
|
=== Local generated process acceptance ===
|
||||||
|
PASS generated root project: go run/build, regeneration, nethttp/native signals; temporary files removed
|
||||||
267
reports/i18nRemote.json
Normal file
267
reports/i18nRemote.json
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.26.0-preview",
|
||||||
|
"frameworkSum": "h1:9Ao/6+8jdqXf4Ia++rhgorTrYclAgrWgXw3U+AWtB4I=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 83.76,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"sharedGoCatalogExport",
|
||||||
|
"documentOnlyErrorSelection",
|
||||||
|
"workspaceCheckNoWrites",
|
||||||
|
"staleCatalogRejected",
|
||||||
|
"twoIndependentServiceBuilds",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"primitiveAndDynamicArrays",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding",
|
||||||
|
"backgroundStopDrainResourceOrder",
|
||||||
|
"backgroundInitAndListenFailureCleanup",
|
||||||
|
"backgroundTimeoutRetainsResourcesAndExits",
|
||||||
|
"remoteHTTPClientRaceTests",
|
||||||
|
"remoteHTTPProxyAndTimeouts",
|
||||||
|
"remoteHTTPHeaderIsolation",
|
||||||
|
"remoteHTTPPoolAndDrainBudgets",
|
||||||
|
"remoteHTTPExample",
|
||||||
|
"remoteExplicitConfigurationRaceTests",
|
||||||
|
"literalAndRequiredDefaultReferences",
|
||||||
|
"remoteCheckConfigBeforeDependencies",
|
||||||
|
"removedImplicitEnvironmentRules",
|
||||||
|
"remotePreciseModuleDiagnostics",
|
||||||
|
"remoteJSONSuccessStatuses",
|
||||||
|
"remoteRejectedStatusBodyCleanup",
|
||||||
|
"remoteCopyTypePreflight",
|
||||||
|
"remoteEmptyCollectionTypeRejection",
|
||||||
|
"remoteLoggerTimeZoneIsolation",
|
||||||
|
"remoteCORSPolicyRace",
|
||||||
|
"remoteCORSErrorResponses",
|
||||||
|
"remoteCORSTransports",
|
||||||
|
"generatedCORSConfigOnly",
|
||||||
|
"remoteTransactionStages",
|
||||||
|
"remoteTransactionCauseChains",
|
||||||
|
"remoteCommitOutcomeUnknown",
|
||||||
|
"remoteCancellationOutcomeUnknown",
|
||||||
|
"remoteTransactionPanicAndConcurrency",
|
||||||
|
"remoteTransactionPublicErrors",
|
||||||
|
"remoteTransactionExample",
|
||||||
|
"remoteI18nRuntimeRace",
|
||||||
|
"remoteI18nGeneratedConsumer",
|
||||||
|
"remoteI18nHTTPBusinessInvariants",
|
||||||
|
"remoteI18nSourceOnlyFallback",
|
||||||
|
"remoteCLDRSamplesAndBrowserConformance",
|
||||||
|
"remoteTypeScriptConsumer",
|
||||||
|
"remoteI18nCLIExtractionCheckExportTypes",
|
||||||
|
"remoteI18nNamespaceIsolation",
|
||||||
|
"remoteBrowserRaceAndFailureRetention",
|
||||||
|
"remoteJSONReaderCausePreservation",
|
||||||
|
"flatFieldComposition",
|
||||||
|
"copyxThreeEndpointConsumer",
|
||||||
|
"explicitNullAndMoneyPolicies",
|
||||||
|
"fieldAdditionWithoutCopyRuleChanges",
|
||||||
|
"middlewareFactoryInjectionAndCleanup",
|
||||||
|
"optionalRepositoryPreservesBusiness",
|
||||||
|
"generatedUnifiedConfig",
|
||||||
|
"jsonYamlXmlAndCustomDecoder",
|
||||||
|
"configValidationBeforeDependencies",
|
||||||
|
"configEnvProvenance",
|
||||||
|
"configExtensionPreserved",
|
||||||
|
"configLoaderDriftCheck"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
|
||||||
|
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
|
||||||
|
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
|
||||||
|
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
|
||||||
|
"contract/bind.go": "25ef07654abe42fe5f7e9cf5e5aa3bf102b311502bdbf16c0346970f87ee3053",
|
||||||
|
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "2305b168701c4564f83d680fc62e6f5a3589f4a3d7e78e66d82a6328bf0a6a89",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
|
||||||
|
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"scripts/verifyManagedRelease.py": "91b87f1688a11b91f7462ce860e06a2ac929db2ba14f381f7aecefddadac0cea",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
|
||||||
|
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
|
||||||
|
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||||
|
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
|
||||||
|
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
|
||||||
|
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
|
||||||
|
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
|
||||||
|
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
|
||||||
|
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
|
||||||
|
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
|
||||||
|
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
|
||||||
|
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
|
||||||
|
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
|
||||||
|
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
|
||||||
|
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
|
||||||
|
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
|
||||||
|
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
|
||||||
|
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
|
||||||
|
"config/config.go": "f8007526a9ebbbc781b2e27e37a49ec768deb568d56846a6ccc62b059a012fd0",
|
||||||
|
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
|
||||||
|
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
|
||||||
|
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
|
||||||
|
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
|
||||||
|
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
|
||||||
|
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
|
||||||
|
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
|
||||||
|
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
|
||||||
|
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
|
||||||
|
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
|
||||||
|
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
|
||||||
|
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
|
||||||
|
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
|
||||||
|
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
|
||||||
|
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
|
||||||
|
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
|
||||||
|
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
|
||||||
|
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
|
||||||
|
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
|
||||||
|
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
|
||||||
|
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
|
||||||
|
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
|
||||||
|
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
|
||||||
|
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
|
||||||
|
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
|
||||||
|
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
|
||||||
|
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
|
||||||
|
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
|
||||||
|
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
|
||||||
|
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
|
||||||
|
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
|
||||||
|
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
|
||||||
|
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
|
||||||
|
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
|
||||||
|
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
|
||||||
|
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
|
||||||
|
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
|
||||||
|
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
|
||||||
|
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
|
||||||
|
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
|
||||||
|
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
|
||||||
|
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
|
||||||
|
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
|
||||||
|
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
|
||||||
|
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
|
||||||
|
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
|
||||||
|
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
|
||||||
|
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
|
||||||
|
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
|
||||||
|
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
|
||||||
|
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
|
||||||
|
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
|
||||||
|
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
|
||||||
|
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
|
||||||
|
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
|
||||||
|
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
|
||||||
|
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
|
||||||
|
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
|
||||||
|
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
|
||||||
|
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
|
||||||
|
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
|
||||||
|
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
|
||||||
|
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6",
|
||||||
|
"i18n/bundle.go": "cd0b54e779fa9f8e3a1b5d4c7456ae06f0f39618365362de329d4b61104abb93",
|
||||||
|
"i18n/types.go": "cc912f7c7dc598de758cec79568fd0bdfac6cab8a8333a6ce0570680236d415a",
|
||||||
|
"i18n/plural_test.go": "ad3a430f370bc51503aa5bce335dcb5962f6e8226f2925956ddd419fd9c230d8",
|
||||||
|
"i18n/export.go": "99608af1d487fced1bba49b77157e7a1f51afb4341fbb607f09920dfca94155a",
|
||||||
|
"i18n/pattern.go": "1c7a91e48f449124d2de90abebd698401aef93896fcdfcf24a83e12a048c5192",
|
||||||
|
"i18n/README.md": "11a933abe712c961ca1c3b138d4440bc440a7b4c6e30654849e604efe4be056c",
|
||||||
|
"i18n/plural.go": "9b69e23568a4ea240d5d4bd2c62237932245a8a91aa451386f4a7d9d76d774f9",
|
||||||
|
"i18n/load.go": "6e43d781ba90db6de78d2138e496161c391797a735211cae7342abebac2c5618",
|
||||||
|
"i18n/bundle_test.go": "f9e30d36955722f51e7d9dfe685515441451459547a3235b97064de7dda4d8f9",
|
||||||
|
"i18n/i18ntool/extract_test.go": "dd62077d190e4264348f18e8f86d25e324eebf2d471918f27da8f0c552bf46c6",
|
||||||
|
"i18n/i18ntool/extract.go": "f0ab49435f61750b06ee8105ff016be5fe06c8404f90163708d9f7e423ae89c0",
|
||||||
|
"i18n/errori18n/errors_test.go": "2c2b74b1752d4367035c9f6b9f0f79bd05dac583f37f4e4de97d2163e667be4d",
|
||||||
|
"i18n/errori18n/errors.go": "539a9eac6b841a566bc808363e9505226c7510d5dc277da621dbefb16cb06ecd",
|
||||||
|
"i18n/browser/runtime.d.mts": "fd2f18dbb6d1b1dfe2392a5e0831354f0fb2ac8815328d8b422ab79503889ad7",
|
||||||
|
"i18n/browser/runtime.test.mjs": "83b219f3b72cdeb417f6223f9f0942ef8d3b43d5051b1c66dc475c7634d65acf",
|
||||||
|
"i18n/browser/README.md": "8a3de821c6cdf57c99ee2b56fa0deeb7557b9d11a9a77bf05b00c1ae22bf34ab",
|
||||||
|
"i18n/browser/conformance.mjs": "95b7325ef24513b53eb5b8d3475fe3c5c7c56bc2da2cb1aa74e097b2ee410623",
|
||||||
|
"i18n/browser/assets.go": "a20c679902c5d96dba13b0df31ef98e3516f0ebdd1c403740a6a720a412b0b84",
|
||||||
|
"i18n/browser/runtime.mjs": "a38f6378a835661ee9198ff910eafd04f4e56051c113d0637703e59e4ae3cd3c",
|
||||||
|
"i18n/browser/testdata/consumer.mts": "84e3e26d2d86a03b30e3fcd27c3bb51c268ac89ef115f8def274321250b46e08",
|
||||||
|
"i18n/cldr/LICENSE": "220ba0e1c43b99530d2d5bdb892a99dca0989414f51ab695ecd90163eaa1ec3b",
|
||||||
|
"i18n/cldr/README.md": "cd5aef06e415cf21913a5b8736c29abbc02df449868c16cd08be8aa7e0c8fea0",
|
||||||
|
"i18n/cldr/plurals.json": "ce19668400e9f7904e8b3939d1f0a2872fe8da7bdea9f45f5d50b3a2bac3b382",
|
||||||
|
"i18n/httpi18n/http_test.go": "1f0266d2d2a54bfe767efe76bfedce6130f6c988dd04be801416336a95a5d606",
|
||||||
|
"i18n/httpi18n/http.go": "ac22eeb795b62b74d0b9a233b6fb79a337f35d430e6a136fb31f643ee4512f0d",
|
||||||
|
"examples/i18n/index.html": "7d0c9e1fdaf96c883cbe8e671ae094d83c2742a9f76b820649d58d62f4944c12",
|
||||||
|
"examples/i18n/config.json": "c3e12efcedbfc6a8ca73cae9cd4cc3617ee363af8747aec630808e6f5e1eb682",
|
||||||
|
"examples/i18n/README.md": "bf5c3ae49b921b1c25d06e19d5ecb743f349ea9d085b9d9da79f1391a29fa9e1",
|
||||||
|
"examples/i18n/main.go": "3f8599781e423a9e3a968f5310fcc80d2daf5f09b733050c1e13c4fea589f29e",
|
||||||
|
"examples/i18n/main_test.go": "c99f736841fe579d2134762d4962da73d6ab88090e5921ce8c26edf5134bee9b",
|
||||||
|
"examples/i18n/locales/ui.en.json": "fa8b210ad1a78baae8f444420c983c5498025a1a3ea0bd15d64d8125629bee42",
|
||||||
|
"examples/i18n/locales/ui.zh-Hans.json": "04761003bea2a02b1e746a1f458f50fa2e0fdb3706d02bc5cb5a4f16378c5021",
|
||||||
|
"examples/i18n/locales/errors.zh-Hans.json": "d9113b720f4c284b90c41a6db4a867d272db4e951cd284eeb00ca69e3fc21385",
|
||||||
|
"app/i18n_test.go": "4d6125b78c33c7df759584b84f791d9a041673d6536503c6671a71e20b588d39",
|
||||||
|
"errors/localization.go": "a047c33e9c4ad9ca10aacfa0800dec51a5db788c1299fba0fb9b361abef41c85",
|
||||||
|
"cmd/go-omni/i18n.go": "be95a89de3fcc34baaea76669f30616902b0f5d7d6ef018e9c29a8e38106fb6a",
|
||||||
|
"cmd/go-omni/apigen/i18nVersion.go": "c4187e5d77164b8e906294b63e8d351db49a6d587b4f77a32529f6a4b75cb1c9",
|
||||||
|
"cmd/go-omni/apigen/testdata/i18n/consumer_test.go": "b62ad30f5912c50e1f6bb1737fd75430ead113989990acb6dcdc93f5d88de0af",
|
||||||
|
"contract/policy_test.go": "d1ea81c923dda58ae75323cc1eb9773517e63180153dbdf19eb35a634c9890db"
|
||||||
|
}
|
||||||
|
}
|
||||||
241
reports/i18nValidation.txt
Normal file
241
reports/i18nValidation.txt
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
=== Go 1.27.1 full repository race ===
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni 2.110s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app 3.749s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/audit (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/auth 2.176s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache/redis (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 4.648s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 29.880s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config 2.829s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/contract (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/copyx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cors (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database 2.260s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/gormx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/migrate (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/mysql (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/errors 3.366s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/configuration 3.039s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/copying (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/i18n 3.686s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 4.007s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 4.383s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 4.158s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/health 3.842s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpc (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx 3.850s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 3.384s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n 3.169s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n 3.277s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool 3.480s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 3.467s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/lifecycle (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/logx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/metrics 3.470s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware 3.697s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 3.573s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/openapi 3.448s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/pagination (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/queue/redis (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/resilience (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/response 3.564s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/local (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/oss (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/task (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/telemetry 2.867s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport 3.380s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 (cached)
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native 4.302s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 3.669s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||||
|
|
||||||
|
=== Go 1.27.1 vet ===
|
||||||
|
(no diagnostics)
|
||||||
|
|
||||||
|
=== Go 1.27.1 build ===
|
||||||
|
(no diagnostics)
|
||||||
|
|
||||||
|
=== Generated consumers ===
|
||||||
|
=== RUN TestErrorInheritanceConsumer
|
||||||
|
--- PASS: TestErrorInheritanceConsumer (4.65s)
|
||||||
|
=== RUN TestErrorParamsConsumer
|
||||||
|
--- PASS: TestErrorParamsConsumer (3.93s)
|
||||||
|
=== RUN TestI18nGeneratedConsumer
|
||||||
|
--- PASS: TestI18nGeneratedConsumer (2.18s)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 12.002s
|
||||||
|
|
||||||
|
=== Go 1.22 full repository: TWO KNOWN FAILURES ===
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni 0.601s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app 3.748s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/audit 2.939s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/auth 0.770s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache 1.306s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 2.414s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 6.033s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||||
|
--- FAIL: TestWorkspaceSharedGoCatalog (0.24s)
|
||||||
|
workspaceCatalog_test.go:36: workspace catalog example.com/catalogs/internal/apierrors.Catalog: build failed (exit status 1); check module dependencies and func() errors.Catalog signature
|
||||||
|
FAIL
|
||||||
|
FAIL git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 33.292s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 4.926s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config 4.921s
|
||||||
|
--- FAIL: TestBodyPolicyPropagatesOuterLimit (0.00s)
|
||||||
|
policy_test.go:32: limit 1: expected 413, got INVALID_REQUEST: request parameters are invalid: unterminated object
|
||||||
|
FAIL
|
||||||
|
FAIL git.yuntuo.hk/k2safe/go-omni/contract 5.102s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/copyx 4.564s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cors 5.632s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database 5.107s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/gormx 5.540s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 4.580s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/mysql 2.955s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/errors 3.089s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/configuration 3.126s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/copying 3.375s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient 3.375s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/i18n 4.191s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 3.972s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 3.265s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 3.128s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 3.181s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/health 3.212s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpc 3.472s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx 3.782s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 3.603s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n 3.390s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n 3.279s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool 2.712s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 2.535s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/lifecycle 2.721s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/logx 2.725s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/metrics 2.734s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware 2.605s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 2.344s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.869s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/pagination 2.125s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 2.273s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/resilience 2.439s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/response 2.687s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage 3.068s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/local 3.291s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 3.284s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 0.837s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/task 1.183s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.391s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport 0.320s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.096s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.570s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.642s
|
||||||
|
FAIL
|
||||||
|
|
||||||
|
=== Go 1.22 feature and integration packages ===
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 0.894s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/errors (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/response (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 3.552s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached)
|
||||||
|
|
||||||
|
=== Go 1.22 i18n generated consumer ===
|
||||||
|
=== RUN TestI18nTranslationMetadataAndVersionBoundary
|
||||||
|
--- PASS: TestI18nTranslationMetadataAndVersionBoundary (0.01s)
|
||||||
|
=== RUN TestI18nGeneratedConsumer
|
||||||
|
--- PASS: TestI18nGeneratedConsumer (2.43s)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 2.866s
|
||||||
|
|
||||||
|
=== CLDR / Go-browser / TypeScript conformance ===
|
||||||
|
=== RUN TestPinnedCLDRSamples
|
||||||
|
plural_test.go:52: validated 5777 upstream cardinal samples
|
||||||
|
--- PASS: TestPinnedCLDRSamples (0.00s)
|
||||||
|
=== RUN TestSourceLocaleCanBeFallbackOnly
|
||||||
|
=== RUN TestSourceLocaleCanBeFallbackOnly/fr
|
||||||
|
=== RUN TestSourceLocaleCanBeFallbackOnly/cmn
|
||||||
|
=== RUN TestSourceLocaleCanBeFallbackOnly/x-example
|
||||||
|
--- PASS: TestSourceLocaleCanBeFallbackOnly (0.10s)
|
||||||
|
--- PASS: TestSourceLocaleCanBeFallbackOnly/fr (0.04s)
|
||||||
|
--- PASS: TestSourceLocaleCanBeFallbackOnly/cmn (0.03s)
|
||||||
|
--- PASS: TestSourceLocaleCanBeFallbackOnly/x-example (0.03s)
|
||||||
|
=== RUN TestBrowserConformance
|
||||||
|
--- PASS: TestBrowserConformance (0.16s)
|
||||||
|
=== RUN TestTypeScriptConsumer
|
||||||
|
--- PASS: TestTypeScriptConsumer (0.55s)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 1.325s
|
||||||
|
|
||||||
|
=== Bounded JSON fuzz ===
|
||||||
|
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
|
||||||
|
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 12 workers
|
||||||
|
fuzz: elapsed: 3s, execs: 264525 (88141/sec), new interesting: 179 (total: 180)
|
||||||
|
fuzz: elapsed: 6s, execs: 264525 (0/sec), new interesting: 179 (total: 180)
|
||||||
|
fuzz: elapsed: 9s, execs: 413057 (49511/sec), new interesting: 186 (total: 187)
|
||||||
|
fuzz: elapsed: 11s, execs: 419121 (2995/sec), new interesting: 192 (total: 193)
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 11.341s
|
||||||
|
|
||||||
|
=== Development-machine benchmark ===
|
||||||
|
goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
pkg: git.yuntuo.hk/k2safe/go-omni/i18n
|
||||||
|
cpu: Apple M2 Max
|
||||||
|
BenchmarkRender-12 623985 3753 ns/op 1040 B/op 27 allocs/op
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 2.745s
|
||||||
|
|
||||||
|
=== Final changed-package race including TypeScript ===
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n 2.518s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached)
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 4.929s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached)
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
- `go vet ./...`、`go build ./...`:通过。
|
- `go vet ./...`、`go build ./...`:通过。
|
||||||
- `go test -race ./database -run '^TestInTxCancellation' -count=20`:通过,包含 20 × 128 次取消与回滚竞争检查。
|
- `go test -race ./database -run '^TestInTxCancellation' -count=20`:通过,包含 20 × 128 次取消与回滚竞争检查。
|
||||||
- `go run ./examples/transactions`:六种结果通过,包括“已保存但提交报错”时不清理文件的演示决策;示例没有真实文件删除。
|
- `go run ./examples/transactions`:六种结果通过,包括“已保存但提交报错”时不清理文件的演示决策;示例没有真实文件删除。
|
||||||
|
- checked-in managedService 的生成检查、race 和根目录 build 通过。
|
||||||
|
|
||||||
| 场景 | 验证结果 |
|
| 场景 | 验证结果 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -61,3 +62,17 @@ go test ./database -run '^$' -bench '^BenchmarkInTx$' -benchmem -benchtime=300ms
|
|||||||
Outcome 只属于当前受管理事务;回调不得自己提交/回滚、执行隐式提交 SQL、把事务交给其他 goroutine。其他连接、独立事务、非事务表、文件和第三方系统的副作用由业务负责。
|
Outcome 只属于当前受管理事务;回调不得自己提交/回滚、执行隐式提交 SQL、把事务交给其他 goroutine。其他连接、独立事务、非事务表、文件和第三方系统的副作用由业务负责。
|
||||||
|
|
||||||
标准库异步回滚不公开的驱动原因无法还原。只拿到 ErrTxDone 时保守返回 Unknown;直接观察到的回滚失败全部保留。panic/Goexit 保持原有传播,没有普通返回值可携带回滚原因。返回错误的文本/具体最外层类型有变化,调用方使用 errors.Is/As;GORM 自己的 Transaction 不属于本次接口。
|
标准库异步回滚不公开的驱动原因无法还原。只拿到 ErrTxDone 时保守返回 Unknown;直接观察到的回滚失败全部保留。panic/Goexit 保持原有传播,没有普通返回值可携带回滚原因。返回错误的文本/具体最外层类型有变化,调用方使用 errors.Is/As;GORM 自己的 Transaction 不属于本次接口。
|
||||||
|
|
||||||
|
## 远程版本验收
|
||||||
|
|
||||||
|
`v0.25.0-preview` 标签固定在 `7a177744ab2bb85f23efc0ec267fd905d349bd4b`,checksum 为 `h1:R1rDYtPBWRfRNHY2Px1gZF3/xpit6Pttd7AvIbMJpKs=`。
|
||||||
|
|
||||||
|
- 空 module 缓存安装 CLI,固定远程依赖、无本地 replace,77 项检查通过,耗时 71.93 秒。9 组生成消费者、下载模块 database/config/httpc/copyx/logx/cors/app 的 race、事务示例、生成检查与业务文件保留通过。见 [transactionRemote.json](transactionRemote.json)。
|
||||||
|
- 独立空缓存下载同一版本,53 条 MySQL 测试记录(含子用例)通过,耗时 28.49 秒。真实 TLS 数据库提交/回滚后的确认丢失、GORM、生成项目与后台 drain 验收通过;独立容器和临时资源已清理。见 [transactionMySQLRemote.json](transactionMySQLRemote.json)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.25.0-preview --report reports/transactionRemote.json
|
||||||
|
python3 scripts/verifyConnector.py --version v0.25.0-preview --gorm --phases --report reports/transactionMySQLRemote.json
|
||||||
|
```
|
||||||
|
|
||||||
|
发布标签不随验收记录提交移动。
|
||||||
|
|||||||
90
reports/transactionMySQLRemote.json
Normal file
90
reports/transactionMySQLRemote.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.25.0-preview",
|
||||||
|
"localSource": false,
|
||||||
|
"gormAdapter": true,
|
||||||
|
"phasedLifecycle": true,
|
||||||
|
"frameworkSum": "h1:R1rDYtPBWRfRNHY2Px1gZF3/xpit6Pttd7AvIbMJpKs=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||||
|
"mysqlLimits": {
|
||||||
|
"cpus": 1,
|
||||||
|
"memoryMiB": 768,
|
||||||
|
"pids": 128
|
||||||
|
},
|
||||||
|
"durationSeconds": 28.49,
|
||||||
|
"tests": [
|
||||||
|
"TestHTTPConfiguredStandardServer",
|
||||||
|
"TestHTTPInvalidStartupReleasesResources",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||||
|
"TestConnectorIndependentPoolsAndContext",
|
||||||
|
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||||
|
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/expired",
|
||||||
|
"TestConnectorIdleZeroAndLifetime",
|
||||||
|
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||||
|
"TestMySQLConnectorDriverLoggerIsolation",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/commitAcknowledgementLost",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/rollbackAcknowledgementLost",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||||
|
"TestHTTPFileAndEnvironment/server.json",
|
||||||
|
"TestHTTPFileAndEnvironment/server.yaml",
|
||||||
|
"TestHTTPFileAndEnvironment",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||||
|
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native",
|
||||||
|
"TestHTTPConfiguredNetworkLimits",
|
||||||
|
"TestStructuredMySQLAdapterTLS",
|
||||||
|
"TestGeneratedOptionalGORM",
|
||||||
|
"TestGeneratedMySQLConfigurationAndLifecycle",
|
||||||
|
"TestGeneratedMySQLBackgroundDrain"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||||
|
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
|
||||||
|
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||||
|
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
|
||||||
|
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||||
|
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||||
|
"scripts/verifyConnector.py": "2b1b1851a53efde49a44642bc92296b4b4f40bf84e0666389ec763a35f63b48a",
|
||||||
|
"scripts/testdata/mysqlGenerated_test.go": "419c83cf9d1c25e8791f92b697f8e74f55b0af76ac0bc41e1316ee87b6923711",
|
||||||
|
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
|
||||||
|
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
|
||||||
|
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||||
|
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||||
|
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||||
|
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||||
|
}
|
||||||
|
}
|
||||||
218
reports/transactionRemote.json
Normal file
218
reports/transactionRemote.json
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.25.0-preview",
|
||||||
|
"frameworkSum": "h1:R1rDYtPBWRfRNHY2Px1gZF3/xpit6Pttd7AvIbMJpKs=",
|
||||||
|
"freshModuleCache": true,
|
||||||
|
"noLocalReplace": true,
|
||||||
|
"durationSeconds": 71.93,
|
||||||
|
"checks": [
|
||||||
|
"remoteCLIInstall",
|
||||||
|
"freshModuleCache",
|
||||||
|
"fixedVersionNoReplace",
|
||||||
|
"contractChangePreview",
|
||||||
|
"persistentLogicSignatureFailure",
|
||||||
|
"businessSourcePreserved",
|
||||||
|
"fixedConsumerBuild",
|
||||||
|
"rootGoRun",
|
||||||
|
"nethttpAndNativeSignals",
|
||||||
|
"listenerReleased",
|
||||||
|
"default404And405",
|
||||||
|
"goOwnedErrorCatalog",
|
||||||
|
"unwrappedDownloads",
|
||||||
|
"rawCallback",
|
||||||
|
"multipart400And413",
|
||||||
|
"nethttpAndNativeTransfers",
|
||||||
|
"sharedGoCatalogExport",
|
||||||
|
"documentOnlyErrorSelection",
|
||||||
|
"workspaceCheckNoWrites",
|
||||||
|
"staleCatalogRejected",
|
||||||
|
"twoIndependentServiceBuilds",
|
||||||
|
"initializationAndListenFailureCleanup",
|
||||||
|
"drainBeforeDependencyClose",
|
||||||
|
"generatedConsumerRaceTests",
|
||||||
|
"errorParamsAndInheritance",
|
||||||
|
"manualHooksAndScalars",
|
||||||
|
"dependencyFatalShutdown",
|
||||||
|
"fatalProcessExitCode",
|
||||||
|
"initializationShutdownNotification",
|
||||||
|
"configuredBuiltinEndpoints",
|
||||||
|
"projectHealthEnvelope",
|
||||||
|
"endpointConflictCleanup",
|
||||||
|
"responseArrayNullableNull",
|
||||||
|
"primitiveAndDynamicArrays",
|
||||||
|
"strictRequestPolicies",
|
||||||
|
"declarativeMultipartBinding",
|
||||||
|
"backgroundStopDrainResourceOrder",
|
||||||
|
"backgroundInitAndListenFailureCleanup",
|
||||||
|
"backgroundTimeoutRetainsResourcesAndExits",
|
||||||
|
"remoteHTTPClientRaceTests",
|
||||||
|
"remoteHTTPProxyAndTimeouts",
|
||||||
|
"remoteHTTPHeaderIsolation",
|
||||||
|
"remoteHTTPPoolAndDrainBudgets",
|
||||||
|
"remoteHTTPExample",
|
||||||
|
"remoteExplicitConfigurationRaceTests",
|
||||||
|
"literalAndRequiredDefaultReferences",
|
||||||
|
"remoteCheckConfigBeforeDependencies",
|
||||||
|
"removedImplicitEnvironmentRules",
|
||||||
|
"remotePreciseModuleDiagnostics",
|
||||||
|
"remoteJSONSuccessStatuses",
|
||||||
|
"remoteRejectedStatusBodyCleanup",
|
||||||
|
"remoteCopyTypePreflight",
|
||||||
|
"remoteEmptyCollectionTypeRejection",
|
||||||
|
"remoteLoggerTimeZoneIsolation",
|
||||||
|
"remoteCORSPolicyRace",
|
||||||
|
"remoteCORSErrorResponses",
|
||||||
|
"remoteCORSTransports",
|
||||||
|
"generatedCORSConfigOnly",
|
||||||
|
"remoteTransactionStages",
|
||||||
|
"remoteTransactionCauseChains",
|
||||||
|
"remoteCommitOutcomeUnknown",
|
||||||
|
"remoteCancellationOutcomeUnknown",
|
||||||
|
"remoteTransactionPanicAndConcurrency",
|
||||||
|
"remoteTransactionPublicErrors",
|
||||||
|
"remoteTransactionExample",
|
||||||
|
"flatFieldComposition",
|
||||||
|
"copyxThreeEndpointConsumer",
|
||||||
|
"explicitNullAndMoneyPolicies",
|
||||||
|
"fieldAdditionWithoutCopyRuleChanges",
|
||||||
|
"middlewareFactoryInjectionAndCleanup",
|
||||||
|
"optionalRepositoryPreservesBusiness",
|
||||||
|
"generatedUnifiedConfig",
|
||||||
|
"jsonYamlXmlAndCustomDecoder",
|
||||||
|
"configValidationBeforeDependencies",
|
||||||
|
"configEnvProvenance",
|
||||||
|
"configExtensionPreserved",
|
||||||
|
"configLoaderDriftCheck"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||||
|
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
|
||||||
|
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
|
||||||
|
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
|
||||||
|
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
|
||||||
|
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||||
|
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
|
||||||
|
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||||
|
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||||
|
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "63a90713cf795c0f317107b011fe29036a4f5b74c6832c11f3913452882b7867",
|
||||||
|
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||||
|
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||||
|
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||||
|
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||||
|
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
|
||||||
|
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||||
|
"scripts/verifyManagedRelease.py": "7bb866bf54729fefdf498f9ada3f5f31898f1f5c4403e753dd2a99674d4ffabc",
|
||||||
|
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||||
|
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
|
||||||
|
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||||
|
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
|
||||||
|
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
|
||||||
|
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||||
|
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
|
||||||
|
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
|
||||||
|
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
|
||||||
|
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
|
||||||
|
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
|
||||||
|
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
|
||||||
|
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
|
||||||
|
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
|
||||||
|
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
|
||||||
|
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
|
||||||
|
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
|
||||||
|
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
|
||||||
|
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
|
||||||
|
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
|
||||||
|
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
|
||||||
|
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
|
||||||
|
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
|
||||||
|
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
|
||||||
|
"config/config.go": "70c8db3b7d5ee5e92b2086a5a18db671e5a3b34ccba19c56514422c11c461c38",
|
||||||
|
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
|
||||||
|
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
|
||||||
|
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
|
||||||
|
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
|
||||||
|
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
|
||||||
|
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
|
||||||
|
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
|
||||||
|
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
|
||||||
|
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
|
||||||
|
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
|
||||||
|
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
|
||||||
|
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
|
||||||
|
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
|
||||||
|
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
|
||||||
|
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
|
||||||
|
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
|
||||||
|
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
|
||||||
|
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
|
||||||
|
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
|
||||||
|
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
|
||||||
|
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
|
||||||
|
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
|
||||||
|
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
|
||||||
|
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
|
||||||
|
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
|
||||||
|
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
|
||||||
|
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
|
||||||
|
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
|
||||||
|
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
|
||||||
|
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
|
||||||
|
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
|
||||||
|
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
|
||||||
|
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
|
||||||
|
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
|
||||||
|
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
|
||||||
|
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
|
||||||
|
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
|
||||||
|
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
|
||||||
|
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
|
||||||
|
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
|
||||||
|
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
|
||||||
|
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
|
||||||
|
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
|
||||||
|
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
|
||||||
|
"database/database.go": "5cf05972b56870d89da5290e3b0db777b0253c3b4214cee3d47ab7fa6d76a263",
|
||||||
|
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
|
||||||
|
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
|
||||||
|
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
|
||||||
|
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
|
||||||
|
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
|
||||||
|
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
|
||||||
|
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
|
||||||
|
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
|
||||||
|
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
|
||||||
|
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
|
||||||
|
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
|
||||||
|
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2",
|
||||||
|
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
|
||||||
|
"database/transaction_test.go": "b8fb122c145836c07ca062dc8c89c07ae7482885d33c3f2c239cca21e97b72d4",
|
||||||
|
"database/transactionResponse_test.go": "bc59b17605c5b5e051fc987caf6dafbbb5d71f4b1380a5ceec8f2dafaafb6af7",
|
||||||
|
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
|
||||||
|
"examples/transactions/main.go": "ac2a17556b63c0bbb8a5c726381b6929df235da089549937a70d3696e2cba3f2",
|
||||||
|
"examples/transactions/demoDriver.go": "950b5f9bf0d20b8b2bc7590d502e110f2815fc394df789cac39e324149870ab6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,6 +187,9 @@ func (f *Formatter) Value(r Result) map[string]any {
|
|||||||
if isError && r.BusinessCode != nil {
|
if isError && r.BusinessCode != nil {
|
||||||
code, message = r.BusinessCode, r.Message
|
code, message = r.BusinessCode, r.Message
|
||||||
}
|
}
|
||||||
|
if r.MessageOverride != nil {
|
||||||
|
message = *r.MessageOverride
|
||||||
|
}
|
||||||
body := c.Body
|
body := c.Body
|
||||||
if isError && c.ErrorBody != nil {
|
if isError && c.ErrorBody != nil {
|
||||||
body = c.ErrorBody
|
body = c.ErrorBody
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user